mirror of
https://github.com/gotson/komga.git
synced 2025-12-28 19:39:20 +01:00
add page for api keys
This commit is contained in:
parent
d9ad255fd1
commit
3aaa313130
17 changed files with 872 additions and 3 deletions
|
|
@ -6,7 +6,7 @@ import type { paths } from '@/generated/openapi/komga'
|
|||
const coladaMiddleware: Middleware = {
|
||||
async onResponse({ response }: { response: Response }) {
|
||||
if (!response.ok) {
|
||||
let body: unknown
|
||||
let body: SpringError = {}
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch (ignoreErr) {}
|
||||
|
|
@ -14,6 +14,7 @@ const coladaMiddleware: Middleware = {
|
|||
cause: {
|
||||
body: body,
|
||||
status: response.status,
|
||||
message: body?.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -34,6 +35,10 @@ const client = createClient<paths>({
|
|||
})
|
||||
client.use(coladaMiddleware)
|
||||
|
||||
interface SpringError {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ErrorCause {
|
||||
body?: unknown
|
||||
status?: number
|
||||
|
|
|
|||
11
next-ui/src/colada/syncpoints.ts
Normal file
11
next-ui/src/colada/syncpoints.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineMutation, useMutation, useQueryCache } from '@pinia/colada'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
|
||||
export const useDeleteSyncPoints = defineMutation(() => {
|
||||
return useMutation({
|
||||
mutation: (keyIds: string[]) =>
|
||||
komgaClient.DELETE('/api/v1/syncpoints/me', {
|
||||
params: { query: { key_id: keyIds } },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import type { components } from '@/generated/openapi/komga'
|
|||
export const QUERY_KEYS_USERS = {
|
||||
root: ['users'] as const,
|
||||
currentUser: ['current-user'] as const,
|
||||
apiKeys: ['current-user', 'api-keys'] as const,
|
||||
}
|
||||
|
||||
export const useUsers = defineQuery(() => {
|
||||
|
|
@ -105,3 +106,47 @@ export const useDeleteUser = defineMutation(() => {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
///////////
|
||||
// API KEYS
|
||||
///////////
|
||||
|
||||
export const useApiKeys = defineQuery(() => {
|
||||
return useQuery({
|
||||
key: () => QUERY_KEYS_USERS.apiKeys,
|
||||
query: () =>
|
||||
komgaClient
|
||||
.GET('/api/v2/users/me/api-keys')
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
})
|
||||
})
|
||||
|
||||
export const useCreateApiKey = defineMutation(() => {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (apiKey: components['schemas']['ApiKeyRequestDto']) =>
|
||||
komgaClient
|
||||
.POST('/api/v2/users/me/api-keys', {
|
||||
body: apiKey,
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => res.data),
|
||||
onSuccess: () => {
|
||||
void queryCache.invalidateQueries({ key: QUERY_KEYS_USERS.apiKeys })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const useDeleteApiKey = defineMutation(() => {
|
||||
const queryCache = useQueryCache()
|
||||
return useMutation({
|
||||
mutation: (keyId: string) =>
|
||||
komgaClient.DELETE('/api/v2/users/me/api-keys/{keyId}', {
|
||||
params: { path: { keyId: keyId } },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryCache.invalidateQueries({ key: QUERY_KEYS_USERS.apiKeys })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
6
next-ui/src/components.d.ts
vendored
6
next-ui/src/components.d.ts
vendored
|
|
@ -9,9 +9,15 @@ export {}
|
|||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AnnouncementCard: typeof import('./components/announcement/Card.vue')['default']
|
||||
ApikeyDeletionWarning: typeof import('./components/apikey/DeletionWarning.vue')['default']
|
||||
ApikeyDetails: typeof import('@/fragments/fragment/apikey/Details.vue')['default']
|
||||
ApikeyForceSyncWarning: typeof import('./components/apikey/ForceSyncWarning.vue')['default']
|
||||
ApikeyTable: typeof import('@/fragments/fragment/apikey/Table.vue')['default']
|
||||
AppFooter: typeof import('./components/AppFooter.vue')['default']
|
||||
DialogConfirm: typeof import('./components/dialog/Confirm.vue')['default']
|
||||
DialogConfirmEdit: typeof import('./components/dialog/ConfirmEdit.vue')['default']
|
||||
FragmentApikeyGenerateDialog: typeof import('./fragments/fragment/apikey/GenerateDialog.vue')['default']
|
||||
FragmentApikeyTable: typeof import('./fragments/fragment/apikey/Table.vue')['default']
|
||||
FragmentBuildCommit: typeof import('./fragments/fragment/BuildCommit.vue')['default']
|
||||
FragmentBuildVersion: typeof import('./fragments/fragment/BuildVersion.vue')['default']
|
||||
FragmentDialogConfirm: typeof import('./fragments/fragment/dialog/Confirm.vue')['default']
|
||||
|
|
|
|||
19
next-ui/src/components/apikey/DeletionWarning.vue
Normal file
19
next-ui/src/components/apikey/DeletionWarning.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<div>The API key will be deleted from this server.</div>
|
||||
<ul class="ps-8">
|
||||
<li>
|
||||
Any application or scripts using this API key will no longer be able to access the Komga
|
||||
API.
|
||||
</li>
|
||||
<li>Authentication activity for this API key will be permanently deleted.</li>
|
||||
</ul>
|
||||
<div class="font-weight-bold mt-4">This action cannot be undone.</div>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
14
next-ui/src/components/apikey/ForceSyncWarning.vue
Normal file
14
next-ui/src/components/apikey/ForceSyncWarning.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<ul class="ps-8">
|
||||
<li>This will delete all sync history for this API key.</li>
|
||||
<li>Your Kobo will sync everything on the next sync.</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
:rules="[['sameAs', validateText]]"
|
||||
hide-details
|
||||
class="mt-2"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
9
next-ui/src/fragments/fragment/apikey/GenerateDialog.mdx
Normal file
9
next-ui/src/fragments/fragment/apikey/GenerateDialog.mdx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './GenerateDialog.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# ApikeyGenerateDialog
|
||||
|
||||
Dialog to create API key. Upon successful creation the dialog will show the key so that it can be copied and safely stored.
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import GenerateDialog from './GenerateDialog.vue'
|
||||
import { expect, waitFor, within, screen } from 'storybook/test'
|
||||
import { delay, http, HttpResponse } from 'msw'
|
||||
|
||||
const meta = {
|
||||
component: GenerateDialog,
|
||||
render: (args: object) => ({
|
||||
components: { GenerateDialog },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<GenerateDialog v-model="args.dialog" />',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {
|
||||
dialog: true,
|
||||
},
|
||||
} satisfies Meta<typeof GenerateDialog>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const Created: Story = {
|
||||
play: async ({ userEvent }) => {
|
||||
const canvas = within(screen.getByRole('dialog'))
|
||||
|
||||
await waitFor(() => expect(canvas.getByText(/kobo sync protocol/i)).toBeVisible())
|
||||
const comment = canvas.getByLabelText(/comment/i, {
|
||||
selector: 'input',
|
||||
})
|
||||
await userEvent.type(comment, 'new key')
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: /generate/i }))
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [http.all('*', async () => await delay(5_000))],
|
||||
},
|
||||
},
|
||||
play: async ({ userEvent }) => {
|
||||
const canvas = within(screen.getByRole('dialog'))
|
||||
|
||||
await waitFor(() => expect(canvas.getByText(/kobo sync protocol/i)).toBeVisible())
|
||||
const comment = canvas.getByLabelText(/comment/i, {
|
||||
selector: 'input',
|
||||
})
|
||||
await userEvent.type(comment, 'long loading')
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: /generate/i }))
|
||||
},
|
||||
}
|
||||
|
||||
export const DuplicateError: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.post('*/api/v2/users/me/api-keys', () =>
|
||||
HttpResponse.json({ message: 'ERR_1034' }, { status: 400 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
play: async ({ userEvent }) => {
|
||||
const canvas = within(screen.getByRole('dialog'))
|
||||
|
||||
await waitFor(() => expect(canvas.getByText(/kobo sync protocol/i)).toBeVisible())
|
||||
const comment = canvas.getByLabelText(/comment/i, {
|
||||
selector: 'input',
|
||||
})
|
||||
await userEvent.type(comment, 'duplicate')
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: /generate/i }))
|
||||
},
|
||||
}
|
||||
187
next-ui/src/fragments/fragment/apikey/GenerateDialog.vue
Normal file
187
next-ui/src/fragments/fragment/apikey/GenerateDialog.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<v-dialog
|
||||
ref="dialogRef"
|
||||
v-model="showDialog"
|
||||
:activator="activator"
|
||||
max-width="600px"
|
||||
@after-leave="reset()"
|
||||
>
|
||||
<template #default="{ isActive }">
|
||||
<v-form @submit.prevent="generateApiKey()">
|
||||
<v-card
|
||||
:title="
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: title',
|
||||
defaultMessage: 'Generate new API key',
|
||||
id: 'ycrpqO',
|
||||
})
|
||||
"
|
||||
:loading="isLoading"
|
||||
>
|
||||
<v-card-text>
|
||||
<div
|
||||
v-if="!createdKey"
|
||||
class="d-flex flex-column ga-6"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: description',
|
||||
defaultMessage:
|
||||
'API Keys can be used to authenticate through the Kobo Sync protocol or the REST API.',
|
||||
id: 'iin6d2',
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="comment"
|
||||
:label="
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: input field label',
|
||||
defaultMessage: 'Comment',
|
||||
id: 'C9LkYh',
|
||||
})
|
||||
"
|
||||
:hint="
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: input field hint',
|
||||
defaultMessage: 'What\'s this API key for?',
|
||||
id: 'oWsqnh',
|
||||
})
|
||||
"
|
||||
:rules="['required']"
|
||||
:error-messages="creationError"
|
||||
@update:modelValue="creationError = ''"
|
||||
:disabled="isLoading || !!createdKey"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-fade-transition>
|
||||
<div
|
||||
v-if="!!createdKey"
|
||||
class="d-flex flex-column ga-6"
|
||||
>
|
||||
<div>
|
||||
<v-alert type="info"
|
||||
>{{
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: message shown after key creation',
|
||||
defaultMessage:
|
||||
"Make sure to copy your API key now. You won't be able to see it again!",
|
||||
id: 'X/Z8x+',
|
||||
})
|
||||
}}
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<v-text-field
|
||||
readonly
|
||||
:label="createdKey.comment"
|
||||
v-model="createdKey.key"
|
||||
>
|
||||
<template
|
||||
#append-inner
|
||||
v-if="clipboardSupported"
|
||||
>
|
||||
<v-fab-transition>
|
||||
<v-icon
|
||||
v-if="copied"
|
||||
icon="i-mdi:check"
|
||||
color="success"
|
||||
/>
|
||||
<v-icon
|
||||
v-else
|
||||
icon="i-mdi:content-copy"
|
||||
@click="copy(createdKey.key)"
|
||||
/>
|
||||
</v-fab-transition>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: close button',
|
||||
defaultMessage: 'Close',
|
||||
id: 'HZqgan',
|
||||
})
|
||||
"
|
||||
@click="isActive.value = false"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'Generate API key dialog: generate button',
|
||||
defaultMessage: 'Generate',
|
||||
id: 'VP+GhR',
|
||||
})
|
||||
"
|
||||
type="submit"
|
||||
:disabled="isLoading || !!createdKey"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntl } from 'vue-intl'
|
||||
import { useCreateApiKey } from '@/colada/users'
|
||||
import type { ErrorCause } from '@/api/komga-client'
|
||||
import { commonMessages } from '@/utils/i18n/common-messages'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import type { VDialog } from 'vuetify/components'
|
||||
|
||||
const intl = useIntl()
|
||||
const messagesStore = useMessagesStore()
|
||||
const { isSupported: clipboardSupported, copy, copied } = useClipboard({ copiedDuring: 3000 })
|
||||
|
||||
const showDialog = defineModel<boolean>('dialog', { required: false })
|
||||
const activator = defineModel<Element | string>('activator', { required: false })
|
||||
|
||||
const comment = ref<string>('')
|
||||
const creationError = ref<string>('')
|
||||
const createdKey = ref<components['schemas']['ApiKeyDto'] | undefined>(undefined)
|
||||
|
||||
const { mutateAsync, isLoading } = useCreateApiKey()
|
||||
|
||||
function generateApiKey() {
|
||||
mutateAsync({ comment: comment.value })
|
||||
.then((key) => (createdKey.value = key))
|
||||
.catch((error) => {
|
||||
console.dir(error)
|
||||
if ((error?.cause as ErrorCause)?.message?.includes('ERR_1034')) {
|
||||
creationError.value = intl.formatMessage({
|
||||
description:
|
||||
'API Key generate dialog: error message displayed when an API key with the same comment already exists',
|
||||
defaultMessage: 'An API key with that comment already exists',
|
||||
id: 'zphiTI',
|
||||
})
|
||||
} else
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message ||
|
||||
intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function reset() {
|
||||
createdKey.value = undefined
|
||||
comment.value = ''
|
||||
creationError.value = ''
|
||||
}
|
||||
</script>
|
||||
11
next-ui/src/fragments/fragment/apikey/Table.mdx
Normal file
11
next-ui/src/fragments/fragment/apikey/Table.mdx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './Table.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# ApikeyTable
|
||||
|
||||
Table showing API keys.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
48
next-ui/src/fragments/fragment/apikey/Table.stories.ts
Normal file
48
next-ui/src/fragments/fragment/apikey/Table.stories.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Table from './Table.vue'
|
||||
import { fn } from 'storybook/test'
|
||||
import { apiKeys } from '@/mocks/api/handlers/users'
|
||||
|
||||
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: {
|
||||
onAddApiKey: fn(),
|
||||
onDeleteApiKey: fn(),
|
||||
onForceSyncApiKey: fn(),
|
||||
onEnterAddApiKey: fn(),
|
||||
onEnterDeleteApiKey: fn(),
|
||||
onEnterForceSyncApiKey: fn(),
|
||||
},
|
||||
} satisfies Meta<typeof Table>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
apiKeys: apiKeys,
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const NoData: Story = {
|
||||
args: {
|
||||
apiKeys: [],
|
||||
},
|
||||
}
|
||||
195
next-ui/src/fragments/fragment/apikey/Table.vue
Normal file
195
next-ui/src/fragments/fragment/apikey/Table.vue
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<v-data-table
|
||||
:loading="loading"
|
||||
:items="apiKeys"
|
||||
: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:key-chain-variant"
|
||||
size="x-small"
|
||||
start
|
||||
/>
|
||||
{{
|
||||
$formatMessage({
|
||||
description: 'API Key table global header',
|
||||
defaultMessage: 'API Keys',
|
||||
id: 'yDWzUi',
|
||||
})
|
||||
}}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-btn
|
||||
class="me-2"
|
||||
prepend-icon="i-mdi:plus"
|
||||
rounded="lg"
|
||||
:text="
|
||||
$formatMessage({
|
||||
description: 'API Key table global header: generate API key button',
|
||||
defaultMessage: 'Generate API Key',
|
||||
id: '6MkTnp',
|
||||
})
|
||||
"
|
||||
border
|
||||
:disabled="loading"
|
||||
@click="emit('addApiKey')"
|
||||
@mouseenter="emit('enterAddApiKey', $event.currentTarget)"
|
||||
/>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<template #no-data>{{
|
||||
$formatMessage({
|
||||
description: 'API Key table: shown when table has no data',
|
||||
defaultMessage: 'No API keys created yet',
|
||||
id: 'WLwQG8',
|
||||
})
|
||||
}}</template>
|
||||
|
||||
<template #[`item.createdDate`]="{ value }">
|
||||
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
|
||||
</template>
|
||||
|
||||
<template #[`item.activity`]="{ value }">
|
||||
<template v-if="value"
|
||||
>{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
|
||||
</template>
|
||||
<template v-else
|
||||
>{{
|
||||
$formatMessage({
|
||||
description:
|
||||
'Shown in API key table when there is no recent authentication activity for the key',
|
||||
defaultMessage: 'No recent activity',
|
||||
id: 'OW1/zn',
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #[`item.actions`]="{ item: apiKey }">
|
||||
<div class="d-flex ga-1 justify-end">
|
||||
<v-icon-btn
|
||||
v-tooltip:bottom="$formatMessage(messages.forceKoboSync)"
|
||||
icon="i-mdi:sync-alert"
|
||||
@click="emit('forceSyncApiKey', apiKey)"
|
||||
@mouseenter="emit('enterForceSyncApiKey', $event.currentTarget)"
|
||||
:aria-label="$formatMessage(messages.forceKoboSync)"
|
||||
/>
|
||||
<v-icon-btn
|
||||
v-tooltip:bottom="$formatMessage(messages.deleteApiKey)"
|
||||
icon="i-mdi:delete"
|
||||
@click="emit('deleteApiKey', apiKey)"
|
||||
@mouseenter="emit('enterDeleteApiKey', $event.currentTarget)"
|
||||
:aria-label="$formatMessage(messages.deleteApiKey)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
import { defineMessage, useIntl } from 'vue-intl'
|
||||
import { komgaClient } from '@/api/komga-client'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
const { apiKeys = [], loading = false } = defineProps<{
|
||||
apiKeys?: components['schemas']['ApiKeyDto'][]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
addApiKey: []
|
||||
enterAddApiKey: [target: Element]
|
||||
enterForceSyncApiKey: [target: Element]
|
||||
enterDeleteApiKey: [target: Element]
|
||||
forceSyncApiKey: [apiKey: components['schemas']['ApiKeyDto']]
|
||||
deleteApiKey: [apiKey: components['schemas']['ApiKeyDto']]
|
||||
}>()
|
||||
|
||||
const hideFooter = computed(() => apiKeys.length < 11)
|
||||
const headers = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'API Key table header: key comment',
|
||||
defaultMessage: 'Comment',
|
||||
id: 'gNiEAF',
|
||||
}),
|
||||
key: 'comment',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'API Key table header: key creation date',
|
||||
defaultMessage: 'Creation date',
|
||||
id: 'mP9Ldq',
|
||||
}),
|
||||
key: 'createdDate',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'API Key table header: key latest activity',
|
||||
defaultMessage: 'Latest activity',
|
||||
id: 'hgiBeR',
|
||||
}),
|
||||
key: 'activity',
|
||||
value: (item: components['schemas']['ApiKeyDto']) => latestActivity[item.id],
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
description: 'API Key table header: key actions',
|
||||
defaultMessage: 'Actions',
|
||||
id: 'rKyTwd',
|
||||
}),
|
||||
key: 'actions',
|
||||
align: 'end',
|
||||
sortable: false,
|
||||
},
|
||||
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
|
||||
|
||||
// store each key's latest activity in a map
|
||||
// when the 'apiKeys' change, we call the API for each key
|
||||
const latestActivity: Record<string, Date | undefined> = reactive({})
|
||||
|
||||
function getLatestActivity(key: components['schemas']['ApiKeyDto']) {
|
||||
komgaClient
|
||||
.GET('/api/v2/users/{id}/authentication-activity/latest', {
|
||||
params: {
|
||||
path: { id: key.userId },
|
||||
query: { apikey_id: key.id },
|
||||
},
|
||||
})
|
||||
// unwrap the openapi-fetch structure on success
|
||||
.then((res) => (latestActivity[key.id] = res.data?.dateTime))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => apiKeys,
|
||||
(apiKeys) => {
|
||||
if (apiKeys)
|
||||
for (const key of apiKeys) {
|
||||
getLatestActivity(key)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const messages = {
|
||||
deleteApiKey: defineMessage({
|
||||
description: 'Tooltip for the delete API key button in the API Key table',
|
||||
defaultMessage: 'Delete API Key',
|
||||
id: 'hude41',
|
||||
}),
|
||||
forceKoboSync: defineMessage({
|
||||
description: 'Tooltip for the force Kobo sync button in the API Key table',
|
||||
defaultMessage: 'Force Kobo sync',
|
||||
id: 't0Tkmy',
|
||||
}),
|
||||
}
|
||||
</script>
|
||||
|
|
@ -30,10 +30,48 @@ const latestActivity = {
|
|||
source: 'Password',
|
||||
}
|
||||
|
||||
const newApiKey = {
|
||||
id: '0JJJHMRR586H7',
|
||||
userId: '0JEDA00AV4Z7G',
|
||||
key: '8dad7cbdb4e3420b9536f726c9123346',
|
||||
comment: 'Kobo Libra',
|
||||
createdDate: new Date('2025-01-21T07:04:42Z'),
|
||||
lastModifiedDate: new Date('2025-01-21T07:04:42Z'),
|
||||
}
|
||||
|
||||
export const apiKeys = [
|
||||
{
|
||||
id: '0JJJHMRR586H7',
|
||||
userId: '0JEDA00AV4Z7G',
|
||||
key: '******',
|
||||
comment: 'Kobo Libra',
|
||||
createdDate: new Date('2025-01-21T07:04:42Z'),
|
||||
lastModifiedDate: new Date('2025-01-21T07:04:42Z'),
|
||||
},
|
||||
{
|
||||
id: '0JQCGQV2Z6NVD',
|
||||
userId: '0JEDA00AV4Z7G',
|
||||
key: '******',
|
||||
comment: 'Komf',
|
||||
createdDate: new Date('2025-02-05T05:51:31Z'),
|
||||
lastModifiedDate: new Date('2025-02-05T05:51:31Z'),
|
||||
},
|
||||
{
|
||||
id: '0K2PJZMP0Q6WA',
|
||||
userId: '0JEDA00AV4Z7G',
|
||||
key: '******',
|
||||
comment: 'Kobo Sage',
|
||||
createdDate: new Date('2025-03-12T09:32:35Z'),
|
||||
lastModifiedDate: new Date('2025-03-12T09:32:35Z'),
|
||||
},
|
||||
]
|
||||
|
||||
export const usersHandlers = [
|
||||
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/{id}/authentication-activity/latest', ({ response }) =>
|
||||
response(200).json(latestActivity),
|
||||
),
|
||||
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)),
|
||||
]
|
||||
|
|
|
|||
11
next-ui/src/pages/account/api-keys.mdx
Normal file
11
next-ui/src/pages/account/api-keys.mdx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
import * as Stories from './api-keys.stories';
|
||||
|
||||
<Meta of={Stories} />
|
||||
|
||||
# Account API Keys
|
||||
|
||||
Page showing the API keys for the current account.
|
||||
|
||||
<Canvas of={Stories.Default} />
|
||||
25
next-ui/src/pages/account/api-keys.stories.ts
Normal file
25
next-ui/src/pages/account/api-keys.stories.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ApiKeys from './api-keys.vue'
|
||||
|
||||
const meta = {
|
||||
component: ApiKeys,
|
||||
render: (args: object) => ({
|
||||
components: { ApiKeys },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<ApiKeys />',
|
||||
}),
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
|
||||
},
|
||||
args: {},
|
||||
} satisfies Meta<typeof ApiKeys>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
|
@ -1,7 +1,166 @@
|
|||
<template>
|
||||
<h1>API Keys</h1>
|
||||
<v-empty-state
|
||||
v-if="error"
|
||||
icon="i-mdi:connection"
|
||||
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
|
||||
:text="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<FragmentApikeyTable
|
||||
:api-keys="apiKeys"
|
||||
:loading="isLoading"
|
||||
@enter-add-api-key="(target) => (dialogGenerateActivator = target)"
|
||||
@enter-force-sync-api-key="(target) => (dialogConfirm.activator = target)"
|
||||
@force-sync-api-key="(apiKey) => showDialog(ACTION.FORCE_SYNC, apiKey)"
|
||||
@enter-delete-api-key="(target) => (dialogConfirm.activator = target)"
|
||||
@delete-api-key="(apiKey) => showDialog(ACTION.DELETE, apiKey)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<FragmentApikeyGenerateDialog :activator="dialogGenerateActivator" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
import { type ErrorCause } from '@/api/komga-client'
|
||||
import type { components } from '@/generated/openapi/komga'
|
||||
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 ApikeyDeletionWarning from '@/components/apikey/DeletionWarning.vue'
|
||||
import ForceSyncWarning from '@/components/apikey/ForceSyncWarning.vue'
|
||||
import { useApiKeys, useDeleteApiKey } from '@/colada/users'
|
||||
import { useDeleteSyncPoints } from '@/colada/syncpoints'
|
||||
|
||||
const intl = useIntl()
|
||||
|
||||
// API data
|
||||
const { data: apiKeys, error, isLoading, refetch: refetchApiKeys } = useApiKeys()
|
||||
|
||||
onMounted(() => refetchApiKeys())
|
||||
|
||||
// Dialogs handling
|
||||
// stores the API Key being actioned upon
|
||||
const apiKeyRecord = ref<components['schemas']['ApiKeyDto']>()
|
||||
// stores the ongoing action, so we can handle the action when the dialog is closed with changes
|
||||
const currentAction = ref<ACTION>()
|
||||
const dialogGenerateActivator = ref<Element | undefined>(undefined)
|
||||
|
||||
const { confirm: dialogConfirm } = storeToRefs(useDialogsStore())
|
||||
|
||||
const { mutateAsync: mutateDeleteApiKey } = useDeleteApiKey()
|
||||
const { mutateAsync: mutateDeleteSyncPoints } = useDeleteSyncPoints()
|
||||
|
||||
const messagesStore = useMessagesStore()
|
||||
|
||||
enum ACTION {
|
||||
DELETE,
|
||||
FORCE_SYNC,
|
||||
}
|
||||
|
||||
function showDialog(action: ACTION, apiKey?: components['schemas']['ApiKeyDto']) {
|
||||
currentAction.value = action
|
||||
switch (action) {
|
||||
case ACTION.DELETE:
|
||||
dialogConfirm.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Delete API Key dialog title',
|
||||
defaultMessage: 'Delete API Key',
|
||||
id: '3beD4X',
|
||||
}),
|
||||
subtitle: apiKey?.comment,
|
||||
maxWidth: 600,
|
||||
validateText: apiKey?.comment,
|
||||
okText: intl.formatMessage({
|
||||
description: 'Delete API Key dialog: confirmation button text',
|
||||
defaultMessage: 'Delete',
|
||||
id: 'IE0XzE',
|
||||
}),
|
||||
closeOnSave: false,
|
||||
}
|
||||
dialogConfirm.value.slotWarning = {
|
||||
component: markRaw(ApikeyDeletionWarning),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirm.value.callback = handleDialogConfirmation
|
||||
break
|
||||
case ACTION.FORCE_SYNC:
|
||||
dialogConfirm.value.dialogProps = {
|
||||
title: intl.formatMessage({
|
||||
description: 'Force Sync API Key dialog title',
|
||||
defaultMessage: 'Force Kobo sync',
|
||||
id: '/lE31l',
|
||||
}),
|
||||
subtitle: apiKey?.comment,
|
||||
maxWidth: 600,
|
||||
validateText: apiKey?.comment,
|
||||
okText: intl.formatMessage({
|
||||
description: 'Force Sync API Key dialog: confirmation button text',
|
||||
defaultMessage: 'I understand',
|
||||
id: 'W3BUf7',
|
||||
}),
|
||||
closeOnSave: false,
|
||||
}
|
||||
dialogConfirm.value.slotWarning = {
|
||||
component: markRaw(ForceSyncWarning),
|
||||
props: {},
|
||||
}
|
||||
dialogConfirm.value.callback = handleDialogConfirmation
|
||||
}
|
||||
apiKeyRecord.value = apiKey
|
||||
}
|
||||
|
||||
function handleDialogConfirmation(
|
||||
hideDialog: () => void,
|
||||
setLoading: (isLoading: boolean) => void,
|
||||
) {
|
||||
let mutation: Promise<unknown> | undefined
|
||||
let successMessage: string | undefined
|
||||
|
||||
setLoading(true)
|
||||
|
||||
switch (currentAction.value) {
|
||||
case ACTION.DELETE:
|
||||
mutation = mutateDeleteApiKey(apiKeyRecord.value!.id)
|
||||
successMessage = intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful API key deletion',
|
||||
defaultMessage: 'API key deleted: {apiKeyComment}',
|
||||
id: 'NArFTo',
|
||||
},
|
||||
{
|
||||
apiKeyComment: apiKeyRecord.value!.comment,
|
||||
},
|
||||
)
|
||||
break
|
||||
case ACTION.FORCE_SYNC:
|
||||
mutation = mutateDeleteSyncPoints([apiKeyRecord.value!.id])
|
||||
successMessage = intl.formatMessage(
|
||||
{
|
||||
description: 'Snackbar notification shown upon successful API key force sync',
|
||||
defaultMessage: 'Kobo sync forced: {apiKeyComment}',
|
||||
id: 'NN7kAK',
|
||||
},
|
||||
{
|
||||
apiKeyComment: apiKeyRecord.value!.comment,
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
mutation
|
||||
?.then(() => {
|
||||
hideDialog()
|
||||
if (successMessage) messagesStore.messages.push({ text: successMessage })
|
||||
})
|
||||
.catch((error) => {
|
||||
messagesStore.messages.push({
|
||||
text:
|
||||
(error?.cause as ErrorCause)?.message || intl.formatMessage(commonMessages.networkError),
|
||||
})
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue