Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user-level access controls to playlists #6383

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 148 additions & 5 deletions src/components/playlisteditor/playlisteditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import 'elements/emby-select/emby-select';

import 'material-design-icons-iconfont';
import '../formdialog.scss';
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api';
import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto';
import type { PlaylistUserPermissions } from '@jellyfin/sdk/lib/generated-client/models/playlist-user-permissions';

interface DialogElement extends HTMLDivElement {
playlistId?: string
Expand Down Expand Up @@ -97,6 +100,7 @@ function createPlaylist(dlg: DialogElement) {
createPlaylistDto: {
Name: name,
IsPublic: dlg.querySelector<HTMLInputElement>('#chkPlaylistPublic')?.checked,
Users: getUsers(dlg),
Ids: itemIds?.split(','),
UserId: apiClient.getCurrentUserId()
}
Expand Down Expand Up @@ -127,6 +131,7 @@ function updatePlaylist(dlg: DialogElement) {
playlistId: dlg.playlistId,
updatePlaylistDto: {
Name: name,
Users: getUsers(dlg),
IsPublic: dlg.querySelector<HTMLInputElement>('#chkPlaylistPublic')?.checked
}
})
Expand Down Expand Up @@ -279,6 +284,21 @@ function getEditorHtml(items: string[], options: PlaylistEditorOptions) {
</div>
</div>`;

html += `
<div>
<div class="sectionTitleContainer flex align-items-center">
<h2 className='sectionTitle'>
Users
</h2>
<button id="btnAddUser" is="emby-button" class="fab submit sectionTitleButton">
<span class="material-icons add" aria-hidden="true"></span>
</button>
</div>

<div class="sharesList paperList"></div>
</div>
`;

// newPlaylistInfo
html += '</div>';

Expand All @@ -295,6 +315,94 @@ function getEditorHtml(items: string[], options: PlaylistEditorOptions) {
return html;
}

function getPlaylistPermissionsHtml() {
let html = '';

html += '<div class="selectContainer-inline">';

html += '<select is="emby-select">';

html += '<option value="0">Read</option>';
html += '<option value="1">Edit</option>';

html += '</select>';

html += '</div>';

return html;
}

function getUsers(page: DialogElement): PlaylistUserPermissions[] {
return Array.prototype.map.call(page.querySelectorAll('.playlistUser'), function (elem) {
return {
UserId: elem.getAttribute('data-user-id'),
CanEdit: Boolean(parseInt(elem.querySelector('select').value, 10))
};
}) as PlaylistUserPermissions[];
}

function getUserImage(user: UserDto) {
const apiClient = ServerConnections.currentApiClient();

let html = '';

if (apiClient && user.Id) {
let imageUrl = 'assets/img/avatar.png';
if (user.PrimaryImageTag) {
imageUrl = apiClient.getUserImageUrl(user.Id, {
width: 35,
tag: user.PrimaryImageTag,
type: 'Primary'
});
}

html += `<img src="${imageUrl}" width="35" height="35" style="border-radius: 100em">`;
}

return html;
}

function addUser(content: DialogElement, user: UserDto, canEdit?: boolean) {
const sharesList = content.querySelector('.sharesList');
if (sharesList) {
let html = '';

html += `<div class="listItem playlistUser" data-user-id="${user.Id}">`;

html += '<div class="listItemBody">';

html += `
<div style="display: flex; align-items: center; gap: 10px;">
${getUserImage(user)}
${user.Name}
</div>`;

html += '</div>';

html += `
${getPlaylistPermissionsHtml()}
<button class="btnDelete listItemButton" is="paper-icon-button-light" type="button" title="Delete">
<span class="material-icons delete" aria-hidden="true"></span>
</button>`;

html += '</div>';

sharesList.insertAdjacentHTML('beforeend', html);
const userElement = sharesList.querySelector(`[data-user-id="${user.Id}"]`);

userElement?.querySelector('.btnDelete')?.addEventListener('click', () => {
userElement.remove();
});

if (canEdit) {
const selectElement = userElement?.querySelector('select');
if (selectElement) {
selectElement.value = canEdit ? '1' : '0';
}
}
}
}

function initEditor(content: DialogElement, options: PlaylistEditorOptions, items: string[]) {
content.querySelector('#selectPlaylistToAddTo')?.addEventListener('change', function(this: HTMLSelectElement) {
if (this.value) {
Expand All @@ -306,7 +414,35 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item
}
});

const apiClient = ServerConnections.getApiClient(currentServerId);
const api = toApi(apiClient);

content.querySelector('form')?.addEventListener('submit', onSubmit);
content.querySelector('#btnAddUser')?.addEventListener('click', (e) => {
e.preventDefault();

const shareUsers = getUsers(content).map(user => user.UserId);

const users = getUserApi(api).getUsers().then(req => {
return req.data.filter(user => user.Id != apiClient.getCurrentUserId() && !shareUsers.includes(user.Id));
}).catch(err => {
console.error('[PlaylistEditor] failed to fetch users', err);
});

import('../userpicker/userpicker').then(({ default: UserPicker }) => {
const picker = new UserPicker();

picker.show({
users: users,
callback: function (selectedUser: UserDto) {
addUser(content, selectedUser);
picker.close();
}
});
}).catch(() => {
console.error('[PlaylistEditor] failed to show user picker');
});
});

const selectedItemsInput = content.querySelector<HTMLInputElement>('.fldSelectedItemIds');
if (selectedItemsInput) {
Expand All @@ -327,23 +463,30 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item
console.error('[PlaylistEditor] could not find dialog element');
return;
}

const apiClient = ServerConnections.getApiClient(currentServerId);
const api = toApi(apiClient);
Promise.all([
getUserLibraryApi(api)
.getItem({ itemId: options.id }),
getPlaylistsApi(api)
.getPlaylist({ playlistId: options.id })
.getPlaylist({ playlistId: options.id }),
getUserApi(api)
.getUsers()
])
.then(([ { data: playlistItem }, { data: playlist } ]) => {
.then(([ { data: playlistItem }, { data: playlist }, { data: users } ]) => {
panel.playlistId = options.id;

const nameField = panel.querySelector<HTMLInputElement>('#txtNewPlaylistName');
if (nameField) nameField.value = playlistItem.Name || '';

const publicField = panel.querySelector<HTMLInputElement>('#chkPlaylistPublic');
if (publicField) publicField.checked = !!playlist.OpenAccess;

playlist.Shares?.forEach(shareUser => {
const user = users.find(u => u.Id == shareUser.UserId);

if (user) {
addUser(panel, user, shareUser.CanEdit);
}
});
})
.catch(err => {
console.error('[playlistEditor] failed to get playlist details', err);
Expand Down
60 changes: 60 additions & 0 deletions src/components/userpicker/userpicker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import dialogHelper from 'components/dialogHelper/dialogHelper';
import globalize from 'lib/globalize';
import template from './userpicker.template.html';

function initUsers(page, users) {
const userSelect = page.querySelector('#selectUser');

const userOptionsHtml = users.map(function (user) {
return '<option value="' + user.Id + '">' + user.Name + '</option>';
});

userSelect.innerHTML += userOptionsHtml;
}

class UserPicker {
show = (options) => {
if (options.users != null) {
options.users.then(users => {
const dlg = dialogHelper.createDialog({
size: 'small',
removeOnClose: true,
scrollY: true
});
dlg.classList.add('ui-body-a');
dlg.classList.add('background-theme-a');
dlg.classList.add('formDialog');
dlg.innerHTML = globalize.translateHtml(template);
this.currentDialog = dlg;
initUsers(dlg, users);
dialogHelper.open(dlg)
.catch(err => {
console.log('[userpicker] failed to open dialog', err);
});

dlg.querySelector('.btnCancel')?.addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('.btnCloseDialog')?.addEventListener('click', () => {
dialogHelper.close(dlg);
});
dlg.querySelector('form').addEventListener('submit', function(e) {
e.preventDefault();
const selectedUserId = this.querySelector('#selectUser')?.value;
if (selectedUserId && options.callback) {
const selectedUser = users.find(user => user.Id == selectedUserId);
options.callback(selectedUser);
}
});
});
}
};

close = () => {
if (this.currentDialog) {
dialogHelper.close(this.currentDialog);
}
};
}

export default UserPicker;
22 changes: 22 additions & 0 deletions src/components/userpicker/userpicker.template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<div class="formDialogHeader">
<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${ButtonBack}">
<span class="material-icons arrow_back" aria-hidden="true"></span>
</button>
<h3 class="formDialogHeaderTitle">Select User</h3>
</div>

<div class="formDialogContent scrollY" style="padding-top:2em;">
<div class="dialogContentInner dialog-content-centered">
<form>
<div class="selectContainer">
<select is="emby-select" id="selectUser" label="User"></select>
</div>

<div class="formDialogFooter">
<button is="emby-button" type="submit" class="raised button-submit block formDialogFooterItem">
<span>${Add}</span>
</button>
</div>
</form>
</div>
</div>
3 changes: 2 additions & 1 deletion src/elements/emby-select/emby-select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
}

.selectContainer-inline {
position: relative;
display: inline-flex;
margin-bottom: 0;
align-items: center;
Expand Down Expand Up @@ -117,7 +118,7 @@

.selectContainer-inline > .selectArrowContainer {
top: initial;
bottom: 0.24em;
bottom: 0.03em;
font-size: 90%;
}

Expand Down
Loading