add page for api keys

This commit is contained in:
Gauthier Roebroeck 2025-07-22 10:57:18 +08:00
parent d9ad255fd1
commit 3aaa313130
17 changed files with 872 additions and 3 deletions

View file

@ -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

View 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 } },
}),
})
})

View file

@ -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 })
},
})
})

View file

@ -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']

View 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>

View 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>

View file

@ -38,6 +38,7 @@
:rules="[['sameAs', validateText]]"
hide-details
class="mt-2"
autofocus
/>
</template>

View 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.

View file

@ -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 }))
},
}

View 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>

View 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} />

View 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: [],
},
}

View 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>

View file

@ -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)),
]

View 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} />

View 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: {},
}

View file

@ -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>