Skip to content

Commit

Permalink
feat(webui): api key support
Browse files Browse the repository at this point in the history
  • Loading branch information
gotson committed Aug 29, 2024
1 parent 210c7b1 commit c1e1da6
Show file tree
Hide file tree
Showing 16 changed files with 547 additions and 54 deletions.
177 changes: 177 additions & 0 deletions komga-webui/src/components/ApiKeyTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<template>
<div style="position: relative">
<div v-if="apiKeys.length > 0">
<v-list elevation="3"
three-line
>
<div v-for="(apiKey, index) in apiKeys" :key="apiKey.id">
<v-list-item>
<v-list-item-content>
<v-list-item-title>{{ apiKey.comment }}</v-list-item-title>
<v-list-item-subtitle>
{{
$t('account_settings.api_key.created_date', {
date:
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(apiKey.createdDate)
})
}}
</v-list-item-subtitle>
<v-list-item-subtitle v-if="apiKeyLastActivity[apiKey.id] !== undefined">
{{
$t('settings_user.latest_activity', {
date:
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(apiKeyLastActivity[apiKey.id])
})
}}
</v-list-item-subtitle>
<v-list-item-subtitle v-else>{{ $t('settings_user.no_recent_activity') }}</v-list-item-subtitle>
</v-list-item-content>

<v-list-item-action>
<v-tooltip bottom>
<template v-slot:activator="{ on }">
<v-btn icon @click="promptSyncPointDelete(apiKey)" v-on="on">
<v-icon>mdi-book-refresh</v-icon>
</v-btn>
</template>
<span>{{ $t('account_settings.api_key.force_kobo_sync') }}</span>
</v-tooltip>
</v-list-item-action>

<v-list-item-action>
<v-btn icon @click="promptDeleteApiKey(apiKey)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>

<v-divider v-if="index !== apiKeys.length-1"/>
</div>
</v-list>

<v-btn fab absolute bottom color="primary"
:right="!$vuetify.rtl"
:left="$vuetify.rtl"
class="mx-6"
small
@click="generateApiKey"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</div>

<div v-else>
<v-container fluid class="pa-0">
<v-row>
<v-col>{{ $t('account_settings.api_key.no_keys') }}</v-col>
</v-row>
<v-row>
<v-col>
<v-btn color="primary" @click="generateApiKey">{{ $t('account_settings.api_key.generate_api_key') }}</v-btn>
</v-col>

</v-row>

</v-container>
</div>

<confirmation-dialog
v-model="modalDeleteSyncPoints"
:title="$t('dialog.force_kobo_sync.dialog_title')"
:body-html="$t('dialog.force_kobo_sync.warning_html')"
:button-confirm="$t('common.i_understand')"
button-confirm-color="warning"
@confirm="deleteSyncPoint"
/>

<confirmation-dialog
v-model="modalDeleteApiKey"
:title="$t('dialog.delete_apikey.dialog_title')"
:body-html="$t('dialog.delete_apikey.warning_html')"
:confirm-text=" $t('dialog.delete_apikey.confirm_delete', {name: apiKeyToDelete.comment})"
:button-confirm="$t('dialog.delete_apikey.button_confirm')"
button-confirm-color="error"
@confirm="deleteApiKey"
/>

<api-key-add-dialog
v-model="modalGenerateApiKey"
@generate="loadApiKeys"
/>
</div>

</template>

<script lang="ts">
import Vue from 'vue'
import {ApiKeyDto} from '@/types/komga-users'
import {ERROR} from '@/types/events'
import ConfirmationDialog from '@/components/dialogs/ConfirmationDialog.vue'
import ApiKeyAddDialog from '@/components/dialogs/ApiKeyAddDialog.vue'
export default Vue.extend({
name: 'ApiKeyTable',
components: {ApiKeyAddDialog, ConfirmationDialog},
data: () => {
return {
apiKeys: [] as ApiKeyDto[],
apiKeyToDelete: {} as ApiKeyDto,
apiKeySyncPointsToDelete: {} as ApiKeyDto,
modalDeleteApiKey: false,
modalDeleteSyncPoints: false,
modalGenerateApiKey: false,
apiKeyLastActivity: {} as any,
}
},
mounted() {
this.loadApiKeys()
},
methods: {
async loadApiKeys() {
try {
this.apiKeys = await this.$komgaUsers.getApiKeys()
this.apiKeys.forEach((a: ApiKeyDto) => {
this.$komgaUsers.getLatestAuthenticationActivityForUser(a.userId, a.id)
.then(value => this.$set(this.apiKeyLastActivity, `${a.id}`, value.dateTime))
.catch(e => {
})
})
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
},
promptDeleteApiKey(apiKey: ApiKeyDto) {
this.apiKeyToDelete = apiKey
this.modalDeleteApiKey = true
},
promptSyncPointDelete(apiKey: ApiKeyDto) {
this.apiKeySyncPointsToDelete = apiKey
this.modalDeleteSyncPoints = true
},
async deleteSyncPoint() {
try {
await this.$komgaSyncPoints.deleteMySyncPointsByApiKey(this.apiKeySyncPointsToDelete.id)
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
},
async deleteApiKey() {
try {
await this.$komgaUsers.deleteApiKey(this.apiKeyToDelete.id)
await this.loadApiKeys()
} catch (e) {
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
},
generateApiKey() {
this.modalGenerateApiKey = true
},
},
})
</script>
3 changes: 2 additions & 1 deletion komga-webui/src/components/AuthenticationActivityTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<script lang="ts">
import Vue from 'vue'
import {ERROR} from '@/types/events'
import { AuthenticationActivityDto } from '@/types/komga-users'
import {AuthenticationActivityDto} from '@/types/komga-users'
export default Vue.extend({
name: 'AuthenticationActivityTable',
Expand Down Expand Up @@ -68,6 +68,7 @@ export default Vue.extend({
{text: this.$t('authentication_activity.user_agent').toString(), value: 'userAgent'},
{text: this.$t('authentication_activity.success').toString(), value: 'success'},
{text: this.$t('authentication_activity.source').toString(), value: 'source'},
{text: this.$t('authentication_activity.api_key').toString(), value: 'apiKeyComment'},
{text: this.$t('authentication_activity.error').toString(), value: 'error'},
{text: this.$t('authentication_activity.datetime').toString(), value: 'dateTime', groupable: false},
)
Expand Down
2 changes: 1 addition & 1 deletion komga-webui/src/components/UsersList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export default Vue.extend({
watch: {
users(val) {
val.forEach((u: UserDto) => {
this.$komgaUsers.getLatestAuthenticationActivityForUser(u)
this.$komgaUsers.getLatestAuthenticationActivityForUser(u.id)
.then(value => this.$set(this.usersLastActivity, `${u.id}`, value.dateTime))
.catch(e => {
})
Expand Down
167 changes: 167 additions & 0 deletions komga-webui/src/components/dialogs/ApiKeyAddDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<template>
<v-dialog v-model="modal"
max-width="600"
>
<v-card>
<v-card-title>{{ $t('dialog.add_api_key.dialog_title') }}</v-card-title>
<v-btn icon absolute top right @click="dialogClose">
<v-icon>mdi-close</v-icon>
</v-btn>

<v-card-text>
<v-container fluid>
<v-row>
<v-col>{{ $t('dialog.add_api_key.context') }}</v-col>
</v-row>
<v-row v-if="!apiKey">
<v-col>
<v-text-field v-model.trim="form.comment"
autofocus
:label="$t('dialog.add_api_key.field_comment')"
:hint="$t('dialog.add_api_key.field_comment_hint')"
:error-messages="getErrors('comment')"
@blur="$v.form.comment.$touch()"
/>
</v-col>
</v-row>

<v-row v-if="apiKey">
<v-col>
<v-alert type="info" class="body-2">{{ $t('dialog.add_api_key.info_copy') }}</v-alert>
</v-col>
</v-row>

<v-row v-if="apiKey">
<v-col>
<v-icon color="success">mdi-check</v-icon>
{{ apiKey.key }}

<v-tooltip top v-model="copied">
<template v-slot:activator="on">
<v-btn v-on="on"
icon
x-small
class="align-content-end"
@click="copyApiKeyToClipboard"
>
<v-icon v-if="copied" color="success">mdi-check</v-icon>
<v-icon v-else>mdi-content-copy</v-icon>
</v-btn>
</template>
<span>{{ $t('common.copied') }}</span>
</v-tooltip>
</v-col>
</v-row>
</v-container>

</v-card-text>

<v-card-actions>
<v-spacer/>
<v-btn text @click="dialogClose">{{ $t('common.close') }}</v-btn>
<v-btn color="primary" @click="generateApiKey" :disabled="apiKey">{{ $t('dialog.add_api_key.button_confirm') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script lang="ts">
import {UserRoles} from '@/types/enum-users'
import Vue from 'vue'
import {required} from 'vuelidate/lib/validators'
import {ERROR} from '@/types/events'
import {ApiKeyDto, ApiKeyRequestDto} from '@/types/komga-users'
function validComment(value: string) {
return !this.alreadyUsedComment.includes(value)
}
export default Vue.extend({
name: 'ApiKeyAddDialog',
data: function () {
return {
UserRoles,
modal: false,
apiKey: undefined as ApiKeyDto,
copied: false,
alreadyUsedComment: [] as string[],
form: {
comment: '',
},
}
},
props: {
value: Boolean,
},
watch: {
value(val) {
this.modal = val
if (val) {
this.clear()
}
},
modal(val) {
!val && this.dialogClose()
},
},
validations: {
form: {
comment: {required, validComment},
},
},
methods: {
clear() {
this.apiKey = undefined
this.alreadyUsedComment = []
this.form.comment = ''
this.$v.$reset()
},
dialogClose() {
this.$emit('input', false)
},
getErrors(fieldName: string): string[] {
const errors = [] as string[]
const field = this.$v.form!![fieldName] as any
if (field && field.$invalid && field.$dirty) {
if (!field.validComment) errors.push(this.$t('error_codes.ERR_1034').toString())
if (!field.required) errors.push(this.$t('common.required').toString())
}
return errors
},
validateInput(): ApiKeyRequestDto {
this.$v.$touch()
if (!this.$v.$invalid) {
return {
comment: this.form.comment,
}
}
return undefined
},
async generateApiKey() {
const apiKeyRequest = this.validateInput()
if (apiKeyRequest) {
try {
this.apiKey = await this.$komgaUsers.createApiKey(apiKeyRequest)
this.$emit('generate')
} catch (e) {
if (e.message.includes('ERR_1034'))
this.alreadyUsedComment.push(this.form.comment)
else
this.$eventHub.$emit(ERROR, {message: e.message} as ErrorEvent)
}
}
},
copyApiKeyToClipboard() {
navigator.clipboard.writeText(this.apiKey.key)
this.copied = true
setTimeout(() => this.copied = false, 3000)
},
},
})
</script>

<style scoped>
</style>
Loading

0 comments on commit c1e1da6

Please sign in to comment.