user creation and revamp edit dialog to support restrictions

This commit is contained in:
Gauthier Roebroeck 2025-05-27 15:17:47 +08:00
parent f4be925fd9
commit a4e92ef798
9 changed files with 305 additions and 51 deletions

View file

@ -2,6 +2,21 @@ import {defineMutation, useMutation, useQueryCache} from '@pinia/colada'
import {komgaClient} from '@/api/komga-client'
import type {components} from '@/generated/openapi/komga'
export const useCreateUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({
mutation: (user: components['schemas']['UserCreationDto']) =>
komgaClient.POST('/api/v2/users', {
body: user,
}),
onSuccess: () => {
void queryCache.invalidateQueries({key: ['users']})
},
onError: (error) => {
console.log('create user error', error)
},
})
})
export const useUpdateUser = defineMutation(() => {
const queryCache = useQueryCache()
return useMutation({

View file

@ -0,0 +1,14 @@
import {defineQuery, useQuery} from '@pinia/colada'
import {komgaClient} from '@/api/komga-client'
export const useLibraries = defineQuery(() => {
return useQuery({
key: () => ['libraries'],
query: () => komgaClient.GET('/api/v1/libraries')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
// 1 hour
staleTime: 60 * 60 * 1000,
gcTime: false,
})
})

View file

@ -0,0 +1,14 @@
import {defineQuery, useQuery} from '@pinia/colada'
import {komgaClient} from '@/api/komga-client'
export const useSharingLabels = defineQuery(() => {
return useQuery({
key: () => ['sharing-labels'],
query: () => komgaClient.GET('/api/v1/sharing-labels')
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
// 1 hour
staleTime: 60 * 60 * 1000,
gcTime: false,
})
})

View file

@ -24,6 +24,7 @@ declare module 'vue' {
DialogConfirm: typeof import('./components/dialogs/DialogConfirm.vue')['default']
DialogConfirmEdit: typeof import('./components/dialogs/DialogConfirmEdit.vue')['default']
FormUserChangePassword: typeof import('./components/forms/user/FormUserChangePassword.vue')['default']
FormUserEdit: typeof import('./components/forms/user/FormUserEdit.vue')['default']
FormUserRoles: typeof import('./components/forms/user/FormUserRoles.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
LoginForm: typeof import('./components/LoginForm.vue')['default']

View file

@ -4,18 +4,20 @@
:rules="[rules.required()]"
label="New password"
autocomplete="off"
autofocus
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showPassword = !showPassword"
/>
<v-text-field
v-model="confirmPassword"
class="mt-2"
:rules="[rules.sameAs(newPassword)]"
label="Confirm password"
autocomplete="off"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="showPassword = !showPassword"
/>
</template>

View file

@ -0,0 +1,180 @@
<template>
<template v-if="!user.id">
<v-text-field
v-model="user!.email"
autofocus
:rules="[rules.required(), rules.email()]"
label="Email"
prepend-icon="mdi-account"
/>
<v-text-field
v-model="user.password"
class="mt-1"
:rules="[rules.required()]"
label="Password"
autocomplete="off"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
prepend-icon="mdi-none"
@click:append-inner="showPassword = !showPassword"
/>
</template>
<!-- Roles -->
<v-select
v-model="user.roles"
chips
closable-chips
multiple
label="Roles"
prepend-icon="mdi-key-chain"
:items="userRoles"
/>
<!-- Shared libraries -->
<v-select
v-model="user.sharedLibraries!.libraryIds"
multiple
label="Shared Libraries"
:items="libraries"
item-title="name"
item-value="id"
prepend-icon="mdi-book-multiple"
>
<!-- Workaround for the lack of a slot to override the whole selection -->
<template #prepend-inner>
<!-- Show an All Libraries chip instead of the selection -->
<v-chip
v-if="user.sharedLibraries?.all"
text="All libraries"
size="small"
/>
</template>
<template #selection="{ item }">
<!-- Show the selection only if 'all' is false -->
<v-chip
v-if="!user.sharedLibraries?.all"
size="small"
:text="item.title"
/>
</template>
<template #prepend-item>
<v-list-item
title="All libraries"
@click="selectAllLibraries"
>
<template #prepend>
<v-checkbox-btn :model-value="user.sharedLibraries?.all" />
</template>
</v-list-item>
</template>
<template #item="{ props: itemProps }">
<v-list-item
:disabled="user.sharedLibraries?.all"
v-bind="itemProps"
>
<template #prepend="{isSelected}">
<v-checkbox-btn :model-value="isSelected" />
</template>
</v-list-item>
</template>
</v-select>
<!-- Age restriction -->
<v-row>
<v-col>
<v-select
v-model="user.ageRestriction!.restriction"
label="Age restriction"
:items="ageRestrictions"
prepend-icon="mdi-folder-lock"
/>
</v-col>
<v-col>
<v-number-input
v-model="user.ageRestriction!.age"
:disabled="user.ageRestriction?.restriction?.toString() === 'NONE'"
label="Age"
:min="0"
:rules="[rules.required()]"
/>
</v-col>
</v-row>
<!-- Allow labels -->
<v-combobox
v-model="user.labelsAllow"
label="Allow only labels"
chips
closable-chips
multiple
:items="sharingLabels"
prepend-icon="mdi-none"
>
<template #prepend-item>
<v-list-item>
<span class="font-weight-medium">Select an item or create one</span>
</v-list-item>
</template>
</v-combobox>
<!-- Exclude labels -->
<v-combobox
v-model="user.labelsExclude"
label="Exclude labels"
chips
closable-chips
multiple
:items="sharingLabels"
prepend-icon="mdi-none"
>
<template #prepend-item>
<v-list-item>
<span class="font-weight-medium">Select an item or create one</span>
</v-list-item>
</template>
</v-combobox>
</template>
<script setup lang="ts">
import {UserRoles} from '@/types/UserRoles.ts'
import type {components} from '@/generated/openapi/komga'
import { useRules } from 'vuetify/labs/rules'
import {useLibraries} from '@/colada/queries/libraries.ts'
import {useSharingLabels} from '@/colada/queries/referential.ts'
const rules = useRules()
interface UserExtend {
id?: string,
email: string,
password?: string,
}
type UserCreation = components["schemas"]["UserCreationDto"] & UserExtend
type UserUpdate = components["schemas"]["UserUpdateDto"] & UserExtend
const user = defineModel<UserCreation | UserUpdate>({required: true})
const showPassword = ref<boolean>(false)
const {data: libraries} = useLibraries()
const {data: sharingLabels} = useSharingLabels()
function selectAllLibraries() {
user.value.sharedLibraries!.all = !user.value.sharedLibraries?.all
user.value.sharedLibraries!.libraryIds = libraries.value?.map(x => x.id) || []
}
const userRoles = computed(() => Object.keys(UserRoles).map(x => ({
title: x,
value: x,
})))
const ageRestrictions = [
{title: 'No restriction', value: 'NONE'},
{title: 'Allow only under', value: 'ALLOW_ONLY'},
{title: 'Exclude over', value: 'EXCLUDE'},
]
</script>

View file

@ -1,22 +0,0 @@
<template>
<v-checkbox
v-for="role in userRoles"
:key="role.value"
v-model="user.roles"
:label="role.text"
:value="role.value"
hide-details
/>
</template>
<script setup lang="ts">
import {UserRoles} from '@/types/UserRoles.ts'
import type {components} from '@/generated/openapi/komga'
const user = defineModel<components["schemas"]["UserDto"]>({required: true})
const userRoles = computed(() => Object.keys(UserRoles).map(x => ({
text: x,
value: x,
})))
</script>

View file

@ -2669,7 +2669,7 @@ export interface components {
/** Format: int32 */
age: number;
/** @enum {string} */
restriction: "ALLOW_ONLY" | "EXCLUDE";
restriction: "ALLOW_ONLY" | "EXCLUDE" | "NONE";
};
AllOfBook: components["schemas"]["Book"] & {
allOf: (components["schemas"]["AllOfBook"] | components["schemas"]["AnyOfBook"] | components["schemas"]["Author"] | components["schemas"]["Deleted"] | components["schemas"]["LibraryId"] | components["schemas"]["MediaProfile"] | components["schemas"]["MediaStatus"] | components["schemas"]["NumberSort"] | components["schemas"]["OneShot"] | components["schemas"]["Poster"] | components["schemas"]["ReadListId"] | components["schemas"]["ReadStatus"] | components["schemas"]["ReleaseDate"] | components["schemas"]["SeriesId"] | components["schemas"]["Tag"] | components["schemas"]["Title"])[];
@ -3885,9 +3885,13 @@ export interface components {
url: string;
};
UserCreationDto: {
ageRestriction?: components["schemas"]["AgeRestrictionUpdateDto"];
email: string;
labelsAllow?: string[];
labelsExclude?: string[];
password: string;
roles: string[];
sharedLibraries?: components["schemas"]["SharedLibrariesUpdateDto"];
};
UserDto: {
ageRestriction?: components["schemas"]["AgeRestrictionDto"];

View file

@ -14,6 +14,30 @@
: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
@ -30,18 +54,11 @@
<template #[`item.actions`]="{ item : user }">
<div class="d-flex ga-1 justify-end">
<v-icon-btn
v-tooltip:bottom="'Reset password'"
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 restrictions'"
icon="mdi-book-lock"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.RESTRICTIONS, user)"
@mouseenter="activator = $event.currentTarget"
/>
<v-icon-btn
v-tooltip:bottom="'Edit user'"
icon="mdi-pencil"
@ -65,7 +82,7 @@
:activator="activator"
:title="dialogTitle"
:subtitle="userRecord?.email"
max-width="400"
:max-width="currentAction === ACTION.PASSWORD ? 400 : 600"
@update:record="handleDialogConfirmation()"
>
<template #text="{proxyModel}">
@ -111,10 +128,11 @@ 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 {useDeleteUser, useUpdateUser, useUpdateUserPassword} from '@/colada/mutations/update-user.ts'
import {useCreateUser, useDeleteUser, useUpdateUser, useUpdateUserPassword} from '@/colada/mutations/update-user.ts'
import FormUserChangePassword from '@/components/forms/user/FormUserChangePassword.vue'
import FormUserRoles from '@/components/forms/user/FormUserRoles.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()
@ -172,30 +190,57 @@ 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 {
EDIT, DELETE, RESTRICTIONS, PASSWORD
ADD, EDIT, DELETE, PASSWORD
}
function showDialog(action: ACTION, user: components["schemas"]["UserDto"]) {
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 Roles'
dialogComponent.value = FormUserRoles
dialogRecord.value = user
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 = FormUserRoles
dialogRecord.value = user
break;
case ACTION.RESTRICTIONS:
dialogTitle.value = 'Edit Restrictions'
dialogComponent.value = FormUserRoles
dialogComponent.value = FormUserEdit
dialogRecord.value = user
break;
case ACTION.PASSWORD:
@ -209,14 +254,15 @@ function showDialog(action: ACTION, user: components["schemas"]["UserDto"]) {
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.RESTRICTIONS:
break;
case ACTION.PASSWORD:
mutateUserPassword({
userId: userRecord.value!.id,