diff --git a/next-ui/src/api/komga-client.ts b/next-ui/src/api/komga-client.ts index 61fd526b2..0c9ff8e7e 100644 --- a/next-ui/src/api/komga-client.ts +++ b/next-ui/src/api/komga-client.ts @@ -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({ }) client.use(coladaMiddleware) +interface SpringError { + message?: string +} + export interface ErrorCause { body?: unknown status?: number diff --git a/next-ui/src/colada/syncpoints.ts b/next-ui/src/colada/syncpoints.ts new file mode 100644 index 000000000..8b8f5eb52 --- /dev/null +++ b/next-ui/src/colada/syncpoints.ts @@ -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 } }, + }), + }) +}) diff --git a/next-ui/src/colada/users.ts b/next-ui/src/colada/users.ts index eb12df944..5739c0dc8 100644 --- a/next-ui/src/colada/users.ts +++ b/next-ui/src/colada/users.ts @@ -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 }) + }, + }) +}) diff --git a/next-ui/src/components.d.ts b/next-ui/src/components.d.ts index 5b153f92a..e98724847 100644 --- a/next-ui/src/components.d.ts +++ b/next-ui/src/components.d.ts @@ -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'] diff --git a/next-ui/src/components/apikey/DeletionWarning.vue b/next-ui/src/components/apikey/DeletionWarning.vue new file mode 100644 index 000000000..f083e0dab --- /dev/null +++ b/next-ui/src/components/apikey/DeletionWarning.vue @@ -0,0 +1,19 @@ + + + diff --git a/next-ui/src/components/apikey/ForceSyncWarning.vue b/next-ui/src/components/apikey/ForceSyncWarning.vue new file mode 100644 index 000000000..53db33b7c --- /dev/null +++ b/next-ui/src/components/apikey/ForceSyncWarning.vue @@ -0,0 +1,14 @@ + + + diff --git a/next-ui/src/components/dialog/Confirm.vue b/next-ui/src/components/dialog/Confirm.vue index 604a39467..9f5f91508 100644 --- a/next-ui/src/components/dialog/Confirm.vue +++ b/next-ui/src/components/dialog/Confirm.vue @@ -38,6 +38,7 @@ :rules="[['sameAs', validateText]]" hide-details class="mt-2" + autofocus /> diff --git a/next-ui/src/fragments/fragment/apikey/GenerateDialog.mdx b/next-ui/src/fragments/fragment/apikey/GenerateDialog.mdx new file mode 100644 index 000000000..a44b8f740 --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/GenerateDialog.mdx @@ -0,0 +1,9 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './GenerateDialog.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. diff --git a/next-ui/src/fragments/fragment/apikey/GenerateDialog.stories.ts b/next-ui/src/fragments/fragment/apikey/GenerateDialog.stories.ts new file mode 100644 index 000000000..e6eb3b2f5 --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/GenerateDialog.stories.ts @@ -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: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: { + dialog: true, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +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 })) + }, +} diff --git a/next-ui/src/fragments/fragment/apikey/GenerateDialog.vue b/next-ui/src/fragments/fragment/apikey/GenerateDialog.vue new file mode 100644 index 000000000..5e4fd5401 --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/GenerateDialog.vue @@ -0,0 +1,187 @@ + + + diff --git a/next-ui/src/fragments/fragment/apikey/Table.mdx b/next-ui/src/fragments/fragment/apikey/Table.mdx new file mode 100644 index 000000000..2a2d1cd7e --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/Table.mdx @@ -0,0 +1,11 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './Table.stories'; + + + +# ApikeyTable + +Table showing API keys. + + diff --git a/next-ui/src/fragments/fragment/apikey/Table.stories.ts b/next-ui/src/fragments/fragment/apikey/Table.stories.ts new file mode 100644 index 000000000..2f112f0cc --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/Table.stories.ts @@ -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: '', + }), + 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 + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + apiKeys: apiKeys, + }, +} + +export const Loading: Story = { + args: { + loading: true, + }, +} + +export const NoData: Story = { + args: { + apiKeys: [], + }, +} diff --git a/next-ui/src/fragments/fragment/apikey/Table.vue b/next-ui/src/fragments/fragment/apikey/Table.vue new file mode 100644 index 000000000..ebc77f8f6 --- /dev/null +++ b/next-ui/src/fragments/fragment/apikey/Table.vue @@ -0,0 +1,195 @@ + + + diff --git a/next-ui/src/mocks/api/handlers/users.ts b/next-ui/src/mocks/api/handlers/users.ts index 4ec94ce3b..061f98705 100644 --- a/next-ui/src/mocks/api/handlers/users.ts +++ b/next-ui/src/mocks/api/handlers/users.ts @@ -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)), ] diff --git a/next-ui/src/pages/account/api-keys.mdx b/next-ui/src/pages/account/api-keys.mdx new file mode 100644 index 000000000..818c7ee83 --- /dev/null +++ b/next-ui/src/pages/account/api-keys.mdx @@ -0,0 +1,11 @@ +import { Canvas, Meta } from '@storybook/addon-docs/blocks'; + +import * as Stories from './api-keys.stories'; + + + +# Account API Keys + +Page showing the API keys for the current account. + + diff --git a/next-ui/src/pages/account/api-keys.stories.ts b/next-ui/src/pages/account/api-keys.stories.ts new file mode 100644 index 000000000..b7eff1cac --- /dev/null +++ b/next-ui/src/pages/account/api-keys.stories.ts @@ -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: '', + }), + parameters: { + // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout + }, + args: {}, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} diff --git a/next-ui/src/pages/account/api-keys.vue b/next-ui/src/pages/account/api-keys.vue index 43d4d2c77..ae2ee8042 100644 --- a/next-ui/src/pages/account/api-keys.vue +++ b/next-ui/src/pages/account/api-keys.vue @@ -1,7 +1,166 @@