feat(settings): user

This commit is contained in:
Radmacher 2023-10-05 17:37:01 +02:00 committed by Fernando Fernández
parent 76e7ec3d74
commit 353fbd45d3
6 changed files with 815 additions and 2 deletions

View File

@ -36,6 +36,7 @@
"byArtist": "By {artist}",
"cancel": "Cancel",
"castAndCrew": "Cast & crew",
"channels": "Channels",
"clipboardFail": "Failed to copy to clipboard",
"clipboardSuccess": "Copied to clipboard",
"clipboardUnsupported": "Copying to clipboard is unsupported in this browser",
@ -183,6 +184,7 @@
"readTheDocumentation": "Read the documentation",
"reportAnIssue": "Report an issue with this client"
},
"liveTv": "Live TV",
"login": {
"addServer": "Add server",
"changeServer": "Change server",
@ -275,6 +277,7 @@
"moreLikeArtist": "More like {artist}",
"moreLikeThis": "More like this",
"movies": "Movies",
"music": "Music",
"name": "Name",
"networks": "Networks",
"nextUp": "Next up",
@ -429,7 +432,46 @@
"noActivityFound": "No activities found",
"noLogsFound": "No logs found"
},
"settings": "Settings"
"settings": "Settings",
"users": {
"avatar": "Avatar",
"edit": "Edit user",
"lastActivityDate": "Last seen",
"libraryAccess": "Library access",
"libraryAccessNote": "Select the libraries to share with this user. Administrators will be able to edit all folders using the metadata manager.",
"new": "Create user",
"tabs": {
"profile": {
"allowRemoteConnections": "Allow remote connections to this server",
"deleteUser": "Delete user",
"deleteUserConfirm": "Are you sure you want to delete this user?",
"name": "Name",
"profile": "Profile"
},
"access": {
"access": "Access",
"allLibraries": "Enable access to all libraries"
},
"parentalControl": {
"addBlockedTag": "Add blocked tag",
"blockUnratedItems": "Block items with no or unregocnized rating information:",
"blockTags": "Block items with tags:",
"maxAllowedRating": "Maximum allowed parental rating",
"maxAllowedRatingSubtitle": "Content with a higher rating will be hidden from this user.",
"parentalControl": "Parental Control",
"tagName": "Tag name",
"unblockTag": "Unblock tag"
},
"password": {
"confirmPassword": "Confirm new password",
"currentPassword": "Current password",
"newPassword": "New password",
"password": "Password",
"resetPassword": "Reset password"
}
},
"users": "Users"
}
},
"settingsSections": {
"account": {

View File

@ -225,7 +225,7 @@ const adminSections = computed(() => {
icon: IMdiAccountMultiple,
name: t('settingsSections.users.name'),
description: t('settingsSections.users.description'),
link: undefined
link: '/settings/users'
},
{
icon: IMdiKeyChain,

View File

@ -0,0 +1,532 @@
<template>
<SettingsPage page-title="settings.users.users">
<template #actions>
<VBtn
variant="elevated"
href="https://jellyfin.org/docs/general/server/users/"
rel="noreferrer noopener"
target="_blank">
{{ t('settings.help') }}
</VBtn>
</template>
<template #content>
<VCard
width="100%"
height="100%">
<VTabs
v-model="tab"
color="deep-purple-accent-4"
align-tabs="center">
<VTab :value="1">
{{ t("settings.users.tabs.profile.profile") }}
</VTab>
<VTab :value="2">
{{ t("settings.users.tabs.access.access") }}
</VTab>
<VTab :value="3">
{{ t("settings.users.tabs.parentalControl.parentalControl") }}
</VTab>
<VTab :value="4">
{{ t("settings.users.tabs.password.password") }}
</VTab>
</VTabs>
<VWindow v-model="tab">
<VWindowItem
:key="1"
:value="1">
<VForm>
<VContainer>
<VRow>
<VCol>
<VTextField
v-model="initializedUser.Name"
:label="t('settings.users.tabs.profile.name')"
hide-details />
</VCol>
</VRow>
<VRow>
<VCol>
<VBtn
:loading="loading"
color="primary"
variant="elevated"
@click="saveProfile">
{{ t('save') }}
</VBtn>
</VCol>
<VCol>
<VBtn
:loading="loading"
color="error"
variant="elevated"
@click="deleteUser">
{{ t('settings.users.tabs.profile.deleteUser') }}
</VBtn>
</VCol>
</VRow>
</VContainer>
</VForm>
</VWindowItem>
<VWindowItem
:key="2"
:value="2">
<VForm>
<VContainer>
<VRow>
<VCol>
<VCheckbox
v-model="initializedUser.CanAccessAllLibraries"
:label="t('settings.users.tabs.access.allLibraries')" />
<VCol />
</vcol>
</VRow>
<div v-if="!initializedUser.CanAccessAllLibraries && libraries">
<VRow>
<div
class="text-subtitle-1 text--secondary font-weight-medium text-capitalize">
{{ $t('settingsSections.libraries.name') }}
</div>
</VRow>
<VRow
v-for="library of libraries.Items"
:key="library.Id">
<VCol>
<VCheckbox
v-model="initializedUser.Folders"
:value="library.Id"
:label="library.Name!" />
</VCol>
</VRow>
</div>
<VRow>
<VCol>
<VBtn
:loading="loading"
color="primary"
variant="elevated"
@click="saveAccess">
{{ t('save') }}
</VBtn>
</VCol>
</VRow>
</VContainer>
</VForm>
</VWindowItem>
<VWindowItem
:key="3"
:value="3">
<VForm>
<VContainer>
<VRow>
<VCol>
<VSelect
v-model="initializedUser.maxParentalRating"
:label="t('settings.users.tabs.parentalControl.maxAllowedRating')"
:items="parentalCategories"
item-title="label"
item-value="id"
hide-details
clearable />
<div
class="text-subtitle-1 text-warning font-weight-medium">
{{ $t('settings.users.tabs.parentalControl.maxAllowedRatingSubtitle') }}
</div>
</VCol>
</VRow>
<VRow>
<VCol>
<div
class="text-subtitle-1 text--secondary font-weight-medium text-capitalize">
{{ $t('settings.users.tabs.parentalControl.blockUnratedItems') }}
</div>
</VCol>
</VRow>
<VContainer>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('books')"
value="Book"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('channels')"
value="ChannelContent"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('liveTv')"
value="LiveTvChannel"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('movies')"
value="Movie"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('music')"
value="Music"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('trailer')"
value="Trailer"
density="compact" />
</VCol>
</VRow>
<VRow dense>
<VCol>
<VCheckbox
v-model="initializedUser.BlockUnratedItems"
:label="t('shows')"
value="Series"
density="compact" />
</VCol>
</VRow>
</VContainer>
<VContainer>
<VRow>
<VCol>
<div
class="text-title font-weight-medium text-capitalize">
{{ t('settings.users.tabs.parentalControl.blockTags') }}
</div>
</VCol>
<VCol>
<VBtn @click="addTagDialogOpen = true">
{{ t('settings.users.tabs.parentalControl.addBlockedTag') }}
</VBtn>
</VCol>
</VRow>
<VRow
v-for="blockedTag of initializedUser.BlockedTags"
:key="blockedTag">
<VCol>
<div
class="text-subtitle-1 font-weight-medium text-capitalize">
{{ blockedTag }}
</div>
</VCol>
<VCol>
<VBtn
:disabled="loading"
color="error"
@click="() => initializedUser.BlockedTags = initializedUser.BlockedTags.filter(tag => tag !== blockedTag)">
{{ t('settings.users.tabs.parentalControl.unblockTag') }}
</VBtn>
</VCol>
</VRow>
</VContainer>
<VRow>
<VCol>
<VBtn
:loading="loading"
color="primary"
variant="elevated"
@click="saveParentalControl">
{{ t('save') }}
</VBtn>
</VCol>
</VRow>
</VContainer>
</VForm>
</VWindowItem>
<VWindowItem
:key="4"
:value="4">
<VForm>
<VContainer>
<VRow>
<VCol>
<VTextField
v-if="user.HasPassword"
v-model="initializedUser.CurrentPassword"
:disabled="loading"
:label="t('settings.users.tabs.password.currentPassword')"
hide-details />
</VCol>
</VRow>
<VRow>
<VCol>
<VTextField
v-model="initializedUser.Password"
:disabled="loading"
:label="t('settings.users.tabs.password.newPassword')"
hide-details />
</VCol>
</VRow>
<VRow>
<VCol>
<VTextField
v-model="initializedUser.ConfirmPassword"
:disabled="loading"
:label="t('settings.users.tabs.password.confirmPassword')"
hide-details />
</VCol>
</VRow>
<VRow>
<VCol>
<VBtn
v-if="user.HasPassword"
:disabled="loading"
:loading="loading"
variant="elevated"
color="error"
@click="resetPassword">
{{ t('settings.users.tabs.password.resetPassword') }}
</VBtn>
</VCol>
<VCol>
<VBtn
:disabled="loading"
variant="elevated"
color="primary"
@click="submitPassword">
{{ t('save') }}
</VBtn>
</VCol>
</VRow>
</VContainer>
</VForm>
</VWindowItem>
</VWindow>
</VCard>
<VDialog
v-model="addTagDialogOpen"
width="auto">
<VCol class="pa-0 add-key-dialog">
<VCard>
<VCardTitle>{{ t('settings.users.tabs.parentalControl.addBlockedTag') }}</VCardTitle>
<VCardActions>
<VForm
class="add-key-form"
@submit.prevent="initializedUser.BlockedTags.push(newTagValue); addTagDialogOpen = false; newTagValue = '';">
<VTextField
v-model="newTagValue"
variant="outlined"
:label="t('settings.users.tabs.parentalControl.tagName')" />
<VBtn
color="primary"
:loading="loading"
:disabled="newTagValue === ''"
@click="initializedUser.BlockedTags.push(newTagValue); addTagDialogOpen = false; newTagValue = '';">
{{ $t('confirm') }}
</VBtn>
<VBtn @click="() => {addTagDialogOpen = false}">
{{ $t('cancel') }}
</VBtn>
</VForm>
</VCardActions>
</VCard>
</VCol>
</VDialog>
</template>
</SettingsPage>
</template>
<route lang="yaml">
meta:
admin: true
</route>
<script setup lang="ts">
import {
BaseItemDtoQueryResult,
UnratedItem,
UserDto
} from '@jellyfin/sdk/lib/generated-client';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute , useRouter } from 'vue-router';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { getLocalizationApi } from '@jellyfin/sdk/lib/utils/api/localization-api';
import { useConfirmDialog, useRemote } from '@/composables';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const remote = useRemote();
const loading = ref<boolean>(false);
const addTagDialogOpen = ref<boolean>(false);
const newTagValue = ref<string>('');
const user = ref<UserDto>({});
const libraries = ref<BaseItemDtoQueryResult>();
const parentalCategories = ref<{ label: string, id: number | undefined }[]>([]);
const initializedUser = ref<{ Name: string, CurrentPassword: string, Password: string, ConfirmPassword: string, CanAccessAllLibraries: boolean, Folders: string[], maxParentalRating: number | undefined, BlockUnratedItems: UnratedItem[], BlockedTags: string[] }>({Name: '', CurrentPassword: '', Password: '', ConfirmPassword: '', CanAccessAllLibraries: false, Folders: [], maxParentalRating: undefined, BlockUnratedItems: [], BlockedTags: []});
const tab = ref<number>(1);
onMounted(async () => {
const { userId } = route.params as { userId: string };
user.value = (await remote.sdk.newUserApi(getUserApi).getUserById({
userId: userId
})).data;
initializeUser();
libraries.value = (await remote.sdk.newUserApi(getLibraryApi).getMediaFolders({isHidden: false})).data;
const cats = (await remote.sdk.newUserApi(getLocalizationApi).getParentalRatings()).data;
for (const cat of cats) {
if (parentalCategories.value.some((c) => c.id === cat.Value!)) {
parentalCategories.value = parentalCategories.value.map((c) => {
if (c.id === cat.Value!) {
return {label: `${c.label}/${cat.Name!}`, id: cat.Value};
}
return c;
});
} else {
parentalCategories.value.push({label: cat.Name!, id: cat.Value!});
}
}
});
/**
* Saves the changed user access
*/
async function saveAccess(): Promise<void> {
if (!user.value.Id) {
return;
}
loading.value = true;
console.log(initializedUser.value.Folders);
await remote.sdk.newUserApi(getUserApi).updateUserPolicy({
userId: user.value.Id,
userPolicy: {...user.value.Policy, EnableAllFolders: initializedUser.value.CanAccessAllLibraries, EnabledFolders: initializedUser.value.Folders}
});
await refreshData();
loading.value = false;
}
/**
* Saves the changed profile
*/
async function saveProfile(): Promise<void> {
if (!user.value.Id) {
return;
}
loading.value = true;
await remote.sdk.newUserApi(getUserApi).updateUser({
userId: user.value.Id,
userDto: {...user.value, Name: initializedUser.value.Name}
});
await refreshData();
loading.value = false;
}
/**
* Saves the changed parental control
*/
async function saveParentalControl(): Promise<void> {
if (!user.value.Id) {
return;
}
loading.value = true;
await remote.sdk.newUserApi(getUserApi).updateUserPolicy({
userId: user.value.Id,
userPolicy: {
...user.value.Policy,
MaxParentalRating: initializedUser.value.maxParentalRating,
BlockUnratedItems: initializedUser.value.BlockUnratedItems
}
});
loading.value = false;
}
/**
* Saves the changed password
*/
async function submitPassword(): Promise<void> {
if (!user.value.Id) {
return;
}
if (!initializedUser.value.Password || initializedUser.value.Password !== initializedUser.value.ConfirmPassword) {
return;
}
loading.value = true;
await remote.sdk.newUserApi(getUserApi).updateUserPassword({userId: user.value.Id, updateUserPassword: {NewPw: initializedUser.value.Password, ...(user.value.HasPassword && {CurrentPw: initializedUser.value.ConfirmPassword})}});
initializedUser.value = {...initializedUser.value, CurrentPassword: '',Password: '', ConfirmPassword: ''};
await refreshData();
loading.value = false;
}
/**
* Refreshes the user data
*/
async function refreshData(): Promise<void> {
if (!user.value.Id) {
return;
}
user.value = (await remote.sdk.newUserApi(getUserApi).getUserById({
userId: user.value.Id
})).data;
initializeUser();
}
/**
* Deletes the user
*/
async function deleteUser():Promise<void> {
await useConfirmDialog(async () => {
await remote.sdk.newUserApi(getUserApi).deleteUser({userId: user.value.Id!});
await router.push('/settings/users');
}, {
title: t('settings.users.tabs.profile.deleteUser'),
text: t('settings.users.tabs.profile.deleteUserConfirm'),
confirmText: t('delete')
});
}
/**
* Resets the password
*/
async function resetPassword(): Promise<void> {
if (!user.value.Id) {
return;
}
loading.value = true;
await remote.sdk.newUserApi(getUserApi).updateUserPassword({userId: user.value.Id, updateUserPassword: {
ResetPassword: true
}});
await refreshData();
loading.value = false;
}
/**
* This function makes sure that all properties are defined
*/
function initializeUser(): void {
initializedUser.value = {...initializedUser.value, CanAccessAllLibraries:user.value.Policy?.EnableAllFolders ?? false, Name: user.value.Name ?? '', Folders: user.value.Policy?.EnabledFolders ?? [], maxParentalRating: user.value.Policy?.MaxParentalRating ?? undefined, BlockUnratedItems: user.value.Policy?.BlockUnratedItems?? [], BlockedTags: user.value.Policy?.BlockedTags ?? []};
}
</script>

View File

@ -0,0 +1,106 @@
<template>
<SettingsPage page-title="settings.users.users">
<template #actions>
<VBtn
color="primary"
variant="elevated"
class="ml-a"
@click="$router.push('/settings/users/new')">
{{ t('settings.users.new') }}
</VBtn>
<VBtn
variant="elevated"
href="https://jellyfin.org/docs/general/server/users/adding-managing-users"
rel="noreferrer noopener"
target="_blank">
{{ t('settings.help') }}
</VBtn>
</template>
<template #content>
<VCol>
<VTable>
<thead>
<tr>
<th
v-for="{ text, value } in headers"
:id="value"
:key="value">
{{ text }}
</th>
<th scope="col">
<!--for avatar-->
</th>
<th scope="col">
<!--for edit btn-->
</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.Id ?? undefined">
<td
v-for="{ value } in headers"
:key="value">
{{
value !== 'LastActivityDate'
? user[value]
: useDateFns(
formatRelative,
parseJSON(user[value] ?? "2023-02-02T02:02:02Z"), //FIXME: using 'unknown' doesnt work here? its used somewhere else in the code as well, does that even work there?
new Date()
).value
}}
</td>
<td>
<VImg
:src="`${remote.sdk.api?.basePath}/Users/${user['Id']}/Images/Primary?height=50&tag=${user['PrimaryImageTag']}&quality=90`"
height="50px"
width="auto" />
</td>
<td>
<VBtn
color="primary"
@click="$router.push(`/settings/users/${user['Id']}`)">
{{ t('settings.users.edit') }}
</VBtn>
</td>
</tr>
</tbody>
</VTable>
</VCol>
</template>
</SettingsPage>
</template>
<route lang="yaml">
meta:
admin: true
</route>
<script setup lang="ts">
import { UserDto } from '@jellyfin/sdk/lib/generated-client';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { formatRelative, parseJSON } from 'date-fns';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDateFns, useRemote } from '@/composables';
const { t } = useI18n();
const remote = useRemote();
const users = ref(
(await remote.sdk.newUserApi(getUserApi).getUsers()).data ?? []
);
const headers = computed<{ text: string; value: keyof UserDto }[]>(() => [
{
text: t('username'),
value: 'Name'
},
{
text: t('settings.users.lastActivityDate'),
value: 'LastActivityDate'
}
]);
</script>

View File

@ -0,0 +1,130 @@
<template>
<SettingsPage page-title="settings.users.new">
<template #actions>
<VBtn
variant="elevated"
href="https://jellyfin.org/docs/general/server/users/"
rel="noreferrer noopener"
target="_blank">
{{ t('settings.help') }}
</VBtn>
</template>
<template #content>
<VCard
width="100%"
height="100%">
<VForm
class="py-5 px-2"
@submit.prevent="createUser">
<VRow>
<VCol>
<VTextField
v-model="name"
:label="t('name')" />
</VCol>
</VRow>
<VRow>
<VCol>
<VTextField
v-model="password"
:label="t('password')" />
</VCol>
</VRow>
<VRow>
<VCol>
<div class="text-subtitle-1 font-weight-medium text-capitalize">
{{ t('settings.users.libraryAccess') }}
</div>
<VCheckbox
v-model="canAccessAllLibraries"
:label="t('settings.users.tabs.access.allLibraries')" />
<VCard
v-if="!canAccessAllLibraries"
:title="t('libraries')"
variant="tonal">
<VRow
v-for="library in libraries?.Items"
:key="library.Id">
<VCol>
<VCheckbox
v-model="accessableLibraries"
:label="library.Name!"
:value="library.Id" />
</VCol>
</VRow>
<div class="text-capitalize text-warning ml-2">
{{ t('settings.users.libraryAccessNote') }}
</div>
</VCard>
</VCol>
</VRow>
<VRow>
<VBtn
color="primary"
variant="elevated"
:loading="loading"
@click="createUser">
{{ t('settings.users.new') }}
</VBtn>
</VRow>
</VForm>
</VCard>
</template>
</SettingsPage>
</template>
<route lang="yaml">
meta:
admin: true
</route>
<script setup lang="ts">
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client';
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
import { useRouter } from 'vue-router';
import { useRemote } from '@/composables';
const { t } = useI18n();
const remote = useRemote();
const router = useRouter();
const name = ref('');
const password = ref('');
const canAccessAllLibraries = ref(true);
const accessableLibraries = ref<string[]>([]);
const libraries = ref<BaseItemDtoQueryResult>();
const loading = ref(false);
libraries.value = (await remote.sdk.newUserApi(getLibraryApi).getMediaFolders({isHidden: false})).data;
/**
* Creates a new user
*/
async function createUser(): Promise<void> {
loading.value = true;
// Create the user
const res = (await remote.sdk.newUserApi(getUserApi).createUserByName({
createUserByName: {
Name: name.value
}
})).data;
// Set the library access policy
await remote.sdk.newUserApi(getUserApi).updateUserPolicy({
userId: res.Id!,
userPolicy: {
...res.Policy,
EnableAllFolders: canAccessAllLibraries.value,
...(!canAccessAllLibraries.value && {
EnabledFolders: accessableLibraries.value
})
}
});
loading.value = false;
await router.push(`/settings/users/${res.Id}`);
}
</script>

View File

@ -58,6 +58,9 @@ declare module 'vue-router/auto/routes' {
'/settings/apikeys': RouteRecordInfo<'/settings/apikeys', '/settings/apikeys', Record<never, never>, Record<never, never>>,
'/settings/devices': RouteRecordInfo<'/settings/devices', '/settings/devices', Record<never, never>, Record<never, never>>,
'/settings/logs-and-activity': RouteRecordInfo<'/settings/logs-and-activity', '/settings/logs-and-activity', Record<never, never>, Record<never, never>>,
'/settings/users/': RouteRecordInfo<'/settings/users/', '/settings/users', Record<never, never>, Record<never, never>>,
'/settings/users/_userId/': RouteRecordInfo<'/settings/users/_userId/', '/settings/users/_userId', Record<never, never>, Record<never, never>>,
'/settings/users/new': RouteRecordInfo<'/settings/users/new', '/settings/users/new', Record<never, never>, Record<never, never>>,
'/wizard': RouteRecordInfo<'/wizard', '/wizard', Record<never, never>, Record<never, never>>,
}
}