komga/next-ui/src/pages/server/users.vue

279 lines
8.4 KiB
Vue

<template>
<v-alert
v-if="error"
type="error"
variant="tonal"
>
Error loading data
</v-alert>
<template v-else>
<v-data-table
:loading="isLoading"
:items="users"
:headers="headers"
:hide-default-footer="hideFooter"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="mdi-account-multiple"
size="x-small"
start
/>
Users
</v-toolbar-title>
<v-btn
class="me-2"
prepend-icon="mdi-plus"
rounded="lg"
text="Add a User"
border
@click="showDialog(ACTION.ADD)"
@mouseenter="activator = $event.currentTarget"
/>
</v-toolbar>
</template>
<template #[`item.roles`]="{ value }">
<div class="d-flex ga-1">
<v-chip
v-for="role in value"
:key="role"
:color="getRoleColor(role)"
:text="role"
size="x-small"
rounded
/>
</div>
</template>
<template #[`item.actions`]="{ item : user }">
<div class="d-flex ga-1 justify-end">
<v-icon-btn
v-tooltip:bottom="'Change password'"
icon="mdi-lock-reset"
@click="showDialog(ACTION.PASSWORD, user)"
@mouseenter="activator = $event.currentTarget"
/>
<v-icon-btn
v-tooltip:bottom="'Edit user'"
icon="mdi-pencil"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.EDIT, user)"
@mouseenter="activator = $event.currentTarget"
/>
<v-icon-btn
v-tooltip:bottom="'Delete user'"
icon="mdi-delete"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.DELETE, user)"
@mouseenter="activatorDelete = $event.currentTarget"
/>
</div>
</template>
</v-data-table>
<DialogConfirmEdit
v-model:record="dialogRecord"
:activator="activator"
:title="dialogTitle"
:subtitle="userRecord?.email"
:max-width="currentAction === ACTION.PASSWORD ? 400 : 600"
@update:record="handleDialogConfirmation()"
>
<template #text="{proxyModel}">
<component
:is="dialogComponent"
v-model="proxyModel.value"
/>
</template>
</DialogConfirmEdit>
<DialogConfirm
:activator="activatorDelete"
:title="dialogTitle"
:subtitle="userRecord?.email"
ok-text="Delete"
:validate-text="userRecord?.email"
max-width="600"
@confirm="handleDialogConfirmation()"
>
<template #warning>
<v-alert
type="warning"
variant="tonal"
class="mb-4"
>
<div>The user account will be deleted from this server.</div>
<ul class="ps-8">
<li>The read progress for this user account will be permanently deleted.</li>
<li>Authentication activity for this user will be permanently deleted.</li>
</ul>
<div class="font-weight-bold mt-4">
This action cannot be undone.
</div>
</v-alert>
</template>
</DialogConfirm>
</template>
</template>
<script lang="ts" setup>
import {useUsers} from '@/colada/queries/users.ts'
import {komgaClient} from '@/api/komga-client.ts'
import type {components} from '@/generated/openapi/komga'
import {useCurrentUser} from '@/colada/queries/current-user.ts'
import {UserRoles} from '@/types/UserRoles.ts'
import {useCreateUser, useDeleteUser, useUpdateUser, useUpdateUserPassword} from '@/colada/mutations/update-user.ts'
import FormUserChangePassword from '@/components/forms/user/FormUserChangePassword.vue'
import FormUserEdit from '@/components/forms/user/FormUserEdit.vue'
import type {Component} from 'vue'
import {useLibraries} from '@/colada/queries/libraries.ts'
// 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>()
// the record passed to the dialog's form's model
const dialogRecord = ref<unknown>()
const activator = ref<Element>()
const activatorDelete = ref<Element>()
const dialogTitle = ref<string>()
// dynamic component for the dialog's inner form
const dialogComponent = shallowRef<Component>()
const {mutate: mutateCreateUser} = useCreateUser()
const {mutate: mutateUser} = useUpdateUser()
const {mutate: mutateUserPassword} = useUpdateUserPassword()
const {mutate: mutateDeleteUser} = useDeleteUser()
const {data: libraries} = useLibraries()
enum ACTION {
ADD, EDIT, DELETE, PASSWORD
}
function showDialog(action: ACTION, user?: components["schemas"]["UserDto"]) {
currentAction.value = action
switch (action) {
case ACTION.ADD:
dialogTitle.value = 'Add User'
dialogComponent.value = FormUserEdit
dialogRecord.value = {
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"]
break;
case ACTION.EDIT:
dialogTitle.value = 'Edit User'
dialogComponent.value = FormUserEdit
dialogRecord.value = {
...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"]
break;
case ACTION.DELETE:
dialogTitle.value = 'Delete User'
dialogComponent.value = FormUserEdit
dialogRecord.value = user
break;
case ACTION.PASSWORD:
dialogTitle.value = 'Change Password'
dialogComponent.value = FormUserChangePassword
// password change initiated with an empty string
dialogRecord.value = ''
}
userRecord.value = user
}
function handleDialogConfirmation() {
switch (currentAction.value) {
case ACTION.ADD:
mutateCreateUser(dialogRecord.value as components["schemas"]["UserCreationDto"])
break;
case ACTION.EDIT:
mutateUser(dialogRecord.value as components["schemas"]["UserDto"])
break;
case ACTION.DELETE:
mutateDeleteUser(userRecord.value!.id)
break;
case ACTION.PASSWORD:
mutateUserPassword({
userId: userRecord.value!.id,
newPassword: dialogRecord.value as string,
})
break;
}
}
</script>
<route lang="yaml">
meta:
requiresRole: ADMIN
</route>