mirror of
https://github.com/gotson/komga.git
synced 2025-12-15 04:53:44 +01:00
381 lines
12 KiB
Vue
381 lines
12 KiB
Vue
<template>
|
|
<v-empty-state
|
|
v-if="error"
|
|
icon="i-mdi:connection"
|
|
:title="$formatMessage(commonMessages.somethingWentWrongTitle)"
|
|
:text="$formatMessage(commonMessages.somethingWentWrongSubTitle)"
|
|
/>
|
|
|
|
<template v-else>
|
|
<v-data-table
|
|
:loading="isLoading"
|
|
:items="users"
|
|
: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:account-multiple"
|
|
size="x-small"
|
|
start
|
|
/>
|
|
Users
|
|
</v-toolbar-title>
|
|
|
|
<v-btn
|
|
class="me-2"
|
|
prepend-icon="i-mdi:plus"
|
|
rounded="lg"
|
|
text="Add a User"
|
|
border
|
|
@click="showDialog(ACTION.ADD)"
|
|
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
|
|
/>
|
|
</v-toolbar>
|
|
</template>
|
|
|
|
<template #[`item.roles`]="{ value }">
|
|
<div class="d-flex ga-1 flex-wrap">
|
|
<v-chip
|
|
v-for="role in value"
|
|
:key="role"
|
|
:color="getRoleColor(role)"
|
|
:text="role"
|
|
size="x-small"
|
|
rounded
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template #[`item.activity`]="{ value }">
|
|
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
|
|
</template>
|
|
|
|
<template #[`item.actions`]="{ item: user }">
|
|
<div class="d-flex ga-1 justify-end">
|
|
<v-icon-btn
|
|
v-tooltip:bottom="$formatMessage(messages.changePassword)"
|
|
icon="i-mdi:lock-reset"
|
|
@click="showDialog(ACTION.PASSWORD, user)"
|
|
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
|
|
:aria-label="$formatMessage(messages.changePassword)"
|
|
/>
|
|
<v-icon-btn
|
|
v-tooltip:bottom="$formatMessage(messages.editUser)"
|
|
icon="i-mdi:pencil"
|
|
:disabled="me?.id == user.id"
|
|
@click="showDialog(ACTION.EDIT, user)"
|
|
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
|
|
:aria-label="$formatMessage(messages.editUser)"
|
|
/>
|
|
<v-icon-btn
|
|
v-tooltip:bottom="$formatMessage(messages.deleteUser)"
|
|
icon="i-mdi:delete"
|
|
:disabled="me?.id == user.id"
|
|
@click="showDialog(ACTION.DELETE, user)"
|
|
@mouseenter="dialogConfirm.activator = $event.currentTarget"
|
|
:aria-label="$formatMessage(messages.deleteUser)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</v-data-table>
|
|
</template>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { useUsers } from '@/colada/queries/users'
|
|
import { type ErrorCause, komgaClient } from '@/api/komga-client'
|
|
import type { components } from '@/generated/openapi/komga'
|
|
import { useCurrentUser } from '@/colada/queries/current-user'
|
|
import { UserRoles } from '@/types/UserRoles'
|
|
import {
|
|
useCreateUser,
|
|
useDeleteUser,
|
|
useUpdateUser,
|
|
useUpdateUserPassword,
|
|
} from '@/colada/mutations/update-user'
|
|
import { useLibraries } from '@/colada/queries/libraries'
|
|
import { commonMessages } from '@/utils/i18n/common-messages'
|
|
import { storeToRefs } from 'pinia'
|
|
import { useDialogsStore } from '@/stores/dialogs'
|
|
import { useMessagesStore } from '@/stores/messages'
|
|
import { defineMessage, useIntl } from 'vue-intl'
|
|
import { useDisplay } from 'vuetify'
|
|
import UserDeletionWarning from '@/components/user/DeletionWarning.vue'
|
|
import UserFormCreateEdit from '@/fragments/fragment/user/form/CreateEdit.vue'
|
|
import UserFormChangePassword from '@/components/user/form/ChangePassword.vue'
|
|
|
|
const intl = useIntl()
|
|
|
|
// API data
|
|
const { data: users, error, isLoading, refetch: refetchUsers } = useUsers()
|
|
const { data: me } = useCurrentUser()
|
|
|
|
// Table
|
|
const hideFooter = computed(() => users.value && users.value.length < 11)
|
|
const headers = [
|
|
{ title: 'Email', key: 'email' },
|
|
{
|
|
title: 'Latest Activity',
|
|
key: 'activity',
|
|
value: (item: components['schemas']['UserDto']) => latestActivity[item.id],
|
|
},
|
|
{ title: 'Roles', value: 'roles', sortable: false },
|
|
{ title: 'Actions', key: 'actions', align: 'end', sortable: false },
|
|
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
|
|
|
|
function getRoleColor(role: UserRoles) {
|
|
if (role === UserRoles.ADMIN) return 'error'
|
|
}
|
|
|
|
// store each user's latest activity in a map
|
|
// when the 'users' change, we call the API for each user
|
|
const latestActivity: Record<string, Date | undefined> = reactive({})
|
|
|
|
function getLatestActivity(userId: string) {
|
|
komgaClient
|
|
.GET('/api/v2/users/{id}/authentication-activity/latest', {
|
|
params: {
|
|
path: { id: userId },
|
|
},
|
|
})
|
|
// unwrap the openapi-fetch structure on success
|
|
.then((res) => (latestActivity[userId] = res.data?.dateTime))
|
|
.catch(() => {})
|
|
}
|
|
|
|
watch(users, (users) => {
|
|
if (users)
|
|
for (const user of users) {
|
|
getLatestActivity(user.id)
|
|
}
|
|
})
|
|
|
|
onMounted(() => refetchUsers())
|
|
|
|
// Dialogs handling
|
|
// stores the user being actioned upon
|
|
const userRecord = ref<components['schemas']['UserDto']>()
|
|
// stores the ongoing action, so we can handle the action when the dialog is closed with changes
|
|
const currentAction = ref<ACTION>()
|
|
|
|
const { confirmEdit: dialogConfirmEdit, confirm: dialogConfirm } = storeToRefs(useDialogsStore())
|
|
|
|
const { mutateAsync: mutateCreateUser } = useCreateUser()
|
|
const { mutateAsync: mutateUser } = useUpdateUser()
|
|
const { mutateAsync: mutateUserPassword } = useUpdateUserPassword()
|
|
const { mutateAsync: mutateDeleteUser } = useDeleteUser()
|
|
const { data: libraries } = useLibraries()
|
|
|
|
const messagesStore = useMessagesStore()
|
|
const display = useDisplay()
|
|
|
|
enum ACTION {
|
|
ADD,
|
|
EDIT,
|
|
DELETE,
|
|
PASSWORD,
|
|
}
|
|
|
|
function showDialog(action: ACTION, user?: components['schemas']['UserDto']) {
|
|
currentAction.value = action
|
|
switch (action) {
|
|
case ACTION.ADD:
|
|
dialogConfirmEdit.value.dialogProps = {
|
|
title: 'Add User',
|
|
maxWidth: 600,
|
|
closeOnSave: false,
|
|
fullscreen: display.xs.value,
|
|
}
|
|
dialogConfirmEdit.value.slot = {
|
|
component: markRaw(UserFormCreateEdit),
|
|
props: {},
|
|
}
|
|
dialogConfirmEdit.value.record = {
|
|
email: '',
|
|
password: '',
|
|
roles: [UserRoles.PAGE_STREAMING, UserRoles.FILE_DOWNLOAD],
|
|
sharedLibraries: {
|
|
all: true,
|
|
// we fill the array with all libraries for a nicer display in the edit dialog
|
|
libraryIds: libraries.value?.map((x) => x.id) || [],
|
|
},
|
|
ageRestriction: {
|
|
age: 0,
|
|
restriction: 'NONE',
|
|
},
|
|
} as components['schemas']['UserCreationDto']
|
|
dialogConfirmEdit.value.callback = handleDialogConfirmation
|
|
break
|
|
case ACTION.EDIT:
|
|
dialogConfirmEdit.value.dialogProps = {
|
|
title: 'Edit User',
|
|
subtitle: user?.email,
|
|
maxWidth: 600,
|
|
closeOnSave: false,
|
|
fullscreen: display.xs.value,
|
|
}
|
|
dialogConfirmEdit.value.slot = {
|
|
component: markRaw(UserFormCreateEdit),
|
|
props: {},
|
|
}
|
|
dialogConfirmEdit.value.record = {
|
|
...user,
|
|
roles: user?.roles.filter((x) => x !== 'USER'),
|
|
sharedLibraries: {
|
|
all: user?.sharedAllLibraries,
|
|
// we fill the array with all libraries for a nicer display in the edit dialog
|
|
libraryIds: user?.sharedAllLibraries
|
|
? libraries.value?.map((x) => x.id) || []
|
|
: user?.sharedLibrariesIds,
|
|
},
|
|
ageRestriction: user?.ageRestriction || {
|
|
age: 0,
|
|
restriction: 'NONE',
|
|
},
|
|
} as components['schemas']['UserUpdateDto']
|
|
dialogConfirmEdit.value.callback = handleDialogConfirmation
|
|
break
|
|
case ACTION.DELETE:
|
|
dialogConfirm.value.dialogProps = {
|
|
title: 'Delete User',
|
|
subtitle: user?.email,
|
|
maxWidth: 600,
|
|
validateText: user?.email,
|
|
okText: 'Delete',
|
|
closeOnSave: false,
|
|
}
|
|
dialogConfirm.value.slotWarning = {
|
|
component: markRaw(UserDeletionWarning),
|
|
props: {},
|
|
}
|
|
dialogConfirm.value.callback = handleDialogConfirmation
|
|
break
|
|
case ACTION.PASSWORD:
|
|
dialogConfirmEdit.value.dialogProps = {
|
|
title: 'Change Password',
|
|
subtitle: user?.email,
|
|
maxWidth: 400,
|
|
closeOnSave: false,
|
|
}
|
|
dialogConfirmEdit.value.slot = {
|
|
component: markRaw(UserFormChangePassword),
|
|
props: {},
|
|
}
|
|
// password change initiated with an empty string
|
|
dialogConfirmEdit.value.record = ''
|
|
dialogConfirmEdit.value.callback = handleDialogConfirmation
|
|
}
|
|
userRecord.value = user
|
|
}
|
|
|
|
function handleDialogConfirmation(
|
|
hideDialog: () => void,
|
|
setLoading: (isLoading: boolean) => void,
|
|
) {
|
|
let mutation: Promise<unknown> | undefined
|
|
let successMessage: string | undefined
|
|
|
|
setLoading(true)
|
|
|
|
switch (currentAction.value) {
|
|
case ACTION.ADD:
|
|
const newUser = dialogConfirmEdit.value.record as components['schemas']['UserCreationDto']
|
|
mutation = mutateCreateUser(newUser)
|
|
successMessage = intl.formatMessage(
|
|
{
|
|
description: 'Snackbar notification shown upon successful user creation',
|
|
defaultMessage: 'User created: {email}',
|
|
id: 'egrxd6',
|
|
},
|
|
{
|
|
email: newUser.email,
|
|
},
|
|
)
|
|
break
|
|
case ACTION.EDIT:
|
|
const editUser = dialogConfirmEdit.value.record as components['schemas']['UserDto']
|
|
mutation = mutateUser(editUser)
|
|
successMessage = intl.formatMessage(
|
|
{
|
|
description: 'Snackbar notification shown upon successful user update',
|
|
defaultMessage: 'User updated: {email}',
|
|
id: 'kvbi4j',
|
|
},
|
|
{
|
|
email: editUser.email,
|
|
},
|
|
)
|
|
break
|
|
case ACTION.DELETE:
|
|
mutation = mutateDeleteUser(userRecord.value!.id)
|
|
successMessage = intl.formatMessage(
|
|
{
|
|
description: 'Snackbar notification shown upon successful user deletion',
|
|
defaultMessage: 'User deleted: {email}',
|
|
id: 'V/OYJE',
|
|
},
|
|
{
|
|
email: userRecord.value!.email,
|
|
},
|
|
)
|
|
break
|
|
case ACTION.PASSWORD:
|
|
mutation = mutateUserPassword({
|
|
userId: userRecord.value!.id,
|
|
newPassword: dialogConfirmEdit.value.record as string,
|
|
})
|
|
successMessage = intl.formatMessage(
|
|
{
|
|
description: "Snackbar notification shown upon successful user's password modification",
|
|
defaultMessage: 'Password changed for user: {email}',
|
|
id: 'JbF1nK',
|
|
},
|
|
{
|
|
email: userRecord.value!.email,
|
|
},
|
|
)
|
|
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)
|
|
})
|
|
}
|
|
|
|
const messages = {
|
|
deleteUser: defineMessage({
|
|
description: 'Tooltip for the delete user button in the users table',
|
|
defaultMessage: 'Delete user',
|
|
id: 'r6CqyT',
|
|
}),
|
|
editUser: defineMessage({
|
|
description: 'Tooltip for the edit user button in the users table',
|
|
defaultMessage: 'Edit user',
|
|
id: 'K40g4r',
|
|
}),
|
|
changePassword: defineMessage({
|
|
description: 'Tooltip for the change password button in the users table',
|
|
defaultMessage: 'Change password',
|
|
id: 'r7xCeA',
|
|
}),
|
|
}
|
|
</script>
|
|
|
|
<route lang="yaml">
|
|
meta:
|
|
requiresRole: ADMIN
|
|
</route>
|