mirror of
https://github.com/gotson/komga.git
synced 2026-05-08 12:35:30 +02:00
add authentication activity pages
This commit is contained in:
parent
22df05757d
commit
d6bfbb7a13
14 changed files with 702 additions and 10 deletions
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
2
next-ui/src/components.d.ts
vendored
2
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -49,8 +49,8 @@
|
|||
:title="
|
||||
$formatMessage({
|
||||
description: 'Drawer menu for My Account > Activity',
|
||||
defaultMessage: 'Activity',
|
||||
id: 'cGFtPg',
|
||||
defaultMessage: 'My activity',
|
||||
id: 'Xx+ITC',
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
39
next-ui/src/mocks/api/pageable.ts
Normal file
39
next-ui/src/mocks/api/pageable.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
10
next-ui/src/pages/server/activity.vue
Normal file
10
next-ui/src/pages/server/activity.vue
Normal 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>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1
next-ui/src/typed-router.d.ts
vendored
1
next-ui/src/typed-router.d.ts
vendored
|
|
@ -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>>,
|
||||
|
|
|
|||
52
next-ui/src/types/PageRequest.ts
Normal file
52
next-ui/src/types/PageRequest.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue