Previously the shared account's libraries were chosen independently of its members, so a group could see a library that one of its members was blocked from - joining a group became a way to gain access. That was especially sharp with auto-created groups, where no admin is in the loop. A shared account is now granted exactly the libraries every member can already reach. If one member is blocked from a library, no group containing them can see it. The account is therefore always a subset of what each member could reach alone, which is what makes creating groups at the login screen safe to leave on by default. Details: - "Enable all folders" is expanded to concrete library ids before intersecting, since it cannot otherwise be compared with an explicit list. Shared accounts are always given an explicit list, never the all-folders permission, so newly added libraries do not silently widen an existing group. - Explicitly blocked folders are subtracted even for members who otherwise have access to everything. - Fails closed: an unresolvable member contributes no access rather than being treated as unrestricted. - Recomputed when membership changes, and re-applied to every group at startup so narrowing a member's own access narrows their groups. Drops the now-meaningless EnableAllFolders/EnabledFolders provisioning inputs and the DynamicGroupsEnableAllFolders setting. Adds 8 tests covering the intersection rules.
242 lines
11 KiB
HTML
242 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Watched Together</title>
|
|
</head>
|
|
<body>
|
|
<div id="WatchedTogetherConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
|
|
data-require="emby-input,emby-button,emby-select,emby-checkbox">
|
|
<div data-role="content">
|
|
<div class="content-primary">
|
|
|
|
<div class="verticalSection">
|
|
<h2 class="sectionTitle">Watched Together</h2>
|
|
<p class="fieldDescription">
|
|
A shared account that several people log into with their own passwords. Anything
|
|
marked watched there is mirrored onto each member's own account.
|
|
This is not synchronized playback — for that, use Jellyfin's built-in SyncPlay.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Existing groups</h3>
|
|
<div id="groupsList"></div>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Create a group</h3>
|
|
<form id="CreateGroupForm">
|
|
<div class="inputContainer">
|
|
<label class="inputLabel inputLabelUnfocused" for="NewGroupName">Account name</label>
|
|
<input id="NewGroupName" name="NewGroupName" type="text" is="emby-input" />
|
|
<div class="fieldDescription">
|
|
Leave blank to join the member names with the separator below. Names are
|
|
cosmetic — membership is tracked internally, not parsed from the name.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="selectContainer">
|
|
<label class="selectLabel" for="MemberSelect">Members (select at least two)</label>
|
|
<select is="emby-select" id="MemberSelect" multiple size="8"
|
|
class="emby-select-withcolor emby-select"></select>
|
|
<div class="fieldDescription">
|
|
Any selected member's password will unlock the shared account.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fieldDescription" style="margin:1em 0">
|
|
The shared account is granted only the libraries <em>every</em> member can
|
|
already reach. If one member is blocked from a library, the group cannot see
|
|
it either, so sharing an account never grants anyone new access.
|
|
</div>
|
|
|
|
<div>
|
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
<span>Create group</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="verticalSection">
|
|
<h3 class="sectionTitle">Settings</h3>
|
|
<form id="SettingsForm">
|
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
|
<label class="emby-checkbox-label">
|
|
<input id="EnableDynamicGroups" type="checkbox" is="emby-checkbox" />
|
|
<span>Create groups automatically at login</span>
|
|
</label>
|
|
<div class="fieldDescription">
|
|
Typing an unrecognised name like <code>alice+bob</code> at the login
|
|
screen creates the shared account on the spot. Every name must belong to
|
|
an existing, enabled user, and the password must be one of theirs.
|
|
An existing account whose name contains the separator always wins.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="inputContainer">
|
|
<label class="inputLabel inputLabelUnfocused" for="NameSeparator">Name separator</label>
|
|
<input id="NameSeparator" name="NameSeparator" type="text" is="emby-input" maxlength="3" />
|
|
<div class="fieldDescription">
|
|
Joins member names into an account name, and is the character split at
|
|
login above. '+' is valid on current Jellyfin; use '_' or '-' if your
|
|
server rejects it.
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
(function () {
|
|
var pluginUniqueId = 'aa3288a0-e8c1-43e2-8045-8c3411142a5b';
|
|
var page;
|
|
|
|
function apiUrl(path) {
|
|
return ApiClient.getUrl('Plugins/WatchedTogether/' + path);
|
|
}
|
|
|
|
function loadEligibleUsers() {
|
|
return ApiClient.getJSON(apiUrl('EligibleUsers')).then(function (users) {
|
|
var select = page.querySelector('#MemberSelect');
|
|
select.innerHTML = users.map(function (u) {
|
|
return '<option value="' + u.UserId + '">' + u.Username + '</option>';
|
|
}).join('');
|
|
});
|
|
}
|
|
|
|
function renderGroups(groups) {
|
|
var container = page.querySelector('#groupsList');
|
|
|
|
if (!groups.length) {
|
|
container.innerHTML = '<p class="fieldDescription">No groups configured yet.</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = groups.map(function (g) {
|
|
var members = g.Members.map(function (m) { return m.Username; }).join(', ');
|
|
var status = g.IsDisabled ? ' <span style="opacity:.7">(disabled)</span>' : '';
|
|
return '<div class="listItem" style="padding:.6em 0;border-bottom:1px solid rgba(255,255,255,.1)">' +
|
|
'<h3 style="margin:0">' + g.SharedUsername + status + '</h3>' +
|
|
'<div class="fieldDescription">Members: ' + members + '</div>' +
|
|
'<div class="fieldDescription">' +
|
|
'Sync unwatched: ' + (g.SyncUnwatched ? 'yes' : 'no') +
|
|
' · Sync play count: ' + (g.SyncPlayCount ? 'yes' : 'no') + '</div>' +
|
|
'<button is="emby-button" type="button" class="raised btnDeleteGroup" ' +
|
|
'data-id="' + g.SharedUserId + '" data-name="' + g.SharedUsername + '">' +
|
|
'<span>Delete</span></button>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.btnDeleteGroup').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var id = btn.getAttribute('data-id');
|
|
var name = btn.getAttribute('data-name');
|
|
// Deleting the account too is destructive, so make it an explicit choice.
|
|
Dashboard.confirm(
|
|
'Also delete the shared account "' + name + '"? Choose Cancel to keep the account and only remove the group.',
|
|
'Delete group',
|
|
function (deleteUser) {
|
|
var url = apiUrl('Groups/' + id + '?deleteSharedUser=' + (deleteUser ? 'true' : 'false'));
|
|
ApiClient.ajax({ type: 'DELETE', url: url }).then(function () {
|
|
Dashboard.alert('Group deleted.');
|
|
loadGroups();
|
|
loadEligibleUsers();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadGroups() {
|
|
return ApiClient.getJSON(apiUrl('Groups')).then(renderGroups);
|
|
}
|
|
|
|
document.querySelector('#WatchedTogetherConfigPage').addEventListener('pageshow', function () {
|
|
page = this;
|
|
Dashboard.showLoadingMsg();
|
|
|
|
Promise.all([
|
|
loadGroups(),
|
|
loadEligibleUsers(),
|
|
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
|
|
page.querySelector('#NameSeparator').value = config.NameSeparator || '+';
|
|
page.querySelector('#EnableDynamicGroups').checked = config.EnableDynamicGroups;
|
|
})
|
|
]).then(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
}, function () {
|
|
Dashboard.hideLoadingMsg();
|
|
});
|
|
});
|
|
|
|
document.querySelector('#CreateGroupForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
|
|
var selected = Array.prototype.slice
|
|
.call(page.querySelector('#MemberSelect').selectedOptions)
|
|
.map(function (o) { return o.value; });
|
|
|
|
if (selected.length < 2) {
|
|
Dashboard.alert('Select at least two members.');
|
|
return false;
|
|
}
|
|
|
|
Dashboard.showLoadingMsg();
|
|
|
|
ApiClient.ajax({
|
|
type: 'POST',
|
|
url: apiUrl('Groups'),
|
|
contentType: 'application/json',
|
|
data: JSON.stringify({
|
|
MemberUserIds: selected,
|
|
Name: page.querySelector('#NewGroupName').value || null
|
|
})
|
|
}).then(function () {
|
|
Dashboard.hideLoadingMsg();
|
|
Dashboard.alert('Group created.');
|
|
page.querySelector('#NewGroupName').value = '';
|
|
loadGroups();
|
|
loadEligibleUsers();
|
|
}, function (response) {
|
|
Dashboard.hideLoadingMsg();
|
|
if (response && response.text) {
|
|
response.text().then(function (msg) {
|
|
Dashboard.alert({ title: 'Could not create group', message: msg });
|
|
});
|
|
} else {
|
|
Dashboard.alert('Could not create group.');
|
|
}
|
|
});
|
|
|
|
return false;
|
|
});
|
|
|
|
document.querySelector('#SettingsForm').addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
Dashboard.showLoadingMsg();
|
|
|
|
ApiClient.getPluginConfiguration(pluginUniqueId).then(function (config) {
|
|
config.NameSeparator = page.querySelector('#NameSeparator').value || '+';
|
|
config.EnableDynamicGroups = page.querySelector('#EnableDynamicGroups').checked;
|
|
ApiClient.updatePluginConfiguration(pluginUniqueId, config).then(function (result) {
|
|
Dashboard.processPluginConfigurationUpdateResult(result);
|
|
});
|
|
});
|
|
|
|
return false;
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|