komga/next-ui/src/pages/server/users.vue
2025-11-28 16:05:20 +08:00

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>