Added dropdown menu for invites, multi-use invites, bump to 0.3.6

Dropdown menu includes time created, and for multi-use invites,
remaining uses, as well as a list of usernames created using the code.
This commit is contained in:
Harvey Tindall 2020-07-10 16:15:17 +01:00
parent db3b992857
commit 27169e4e0d
8 changed files with 217 additions and 26 deletions

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
__version__ = "0.3.5"
__version__ = "0.3.6"
import secrets
import configparser

View File

@ -94,16 +94,31 @@
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.rotated {
transform: rotate(180deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
.not-rotated {
transform: rotate(0deg);
-webkit-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-moz-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
-o-transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000);
transition: all 150ms cubic-bezier(0.770, 0.000, 0.175, 1.000); /* easeInOutQuart */
}
</style>
<title>Admin</title>
@ -263,16 +278,17 @@
</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked; document.getElementById('noUseLimit').checked = false;" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('multiUseCount').disabled = !this.checked; document.getElementById('noUseLimit').disabled = !this.checked" aria-label="Checkbox to allow choice of invite usage limit" name="multiple-uses" id="multiUseEnabled">
</div>
<input type="number" class="form-control" name="remaining-uses" id="multiUseCount">
</div>
</div>
<div class="form-group form-check" style="margin-top: 1rem; margin-bottom: 1rem;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked;">
<input class="form-check-input" type="checkbox" value="" name="no-limit" id="noUseLimit" onchange="document.getElementById('multiUseCount').disabled = this.checked; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
No limit
No use limit
</label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div>
{% if email_enabled %}
<div class="form-group">

View File

@ -158,7 +158,7 @@ var userDefaultsModal = createModal('userDefaults');
var usersModal = createModal('users');
var restartModal = createModal('restartModal');
// Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>]
// Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>]
function parseInvite(invite, empty = false) {
if (empty) {
return ["None", "", 1];
@ -170,14 +170,30 @@ function parseInvite(invite, empty = false) {
time += `${invite[m]}${m[0]} `;
}
}
i[1] = `Expires in ${time.slice(0, -1)}`
i[1] = `Expires in ${time.slice(0, -1)}`;
if ('remaining-uses' in invite) {
i[4] = invite['remaining-uses'];
}
if (invite['no-limit']) {
i[4] = '∞';
}
if ('used-by' in invite) {
i[5] = invite['used-by'];
} else {
i[5] = [];
}
if ('created' in invite) {
i[6] = invite['created'];
}
return i;
}
function addItem(parsedInvite) {
let links = document.getElementById('invites');
let listItem = document.createElement('li');
listItem.id = parsedInvite[0];
let itemContainer = document.createElement('div');
itemContainer.id = parsedInvite[0];
let listItem = document.createElement('div');
// listItem.id = parsedInvite[0];
listItem.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
let code = document.createElement('div');
@ -199,7 +215,6 @@ function addItem(parsedInvite) {
listRight.appendChild(listText);
if (parsedInvite[2] == 0) {
// Check this works!
let inviteCode = window.location.href.split('#')[0] + 'invite/' + parsedInvite[0];
//
codeLink.href = inviteCode;
@ -227,15 +242,122 @@ function addItem(parsedInvite) {
deleteButton.textContent = "Delete";
listRight.appendChild(deleteButton);
let dropButton = document.createElement('i');
dropButton.classList.add('fa', 'fa-angle-down', 'collapsed', 'icon-button', 'not-rotated');
dropButton.setAttribute('data-toggle', 'collapse');
dropButton.setAttribute('aria-expanded', 'false');
dropButton.setAttribute('data-target', '#' + CSS.escape(parsedInvite[0]) + '_collapse');
dropButton.onclick = function() {
if (this.classList.contains('rotated')) {
this.classList.remove('rotated');
this.classList.add('not-rotated');
} else {
this.classList.remove('not-rotated');
this.classList.add('rotated');
}
};
dropButton.setAttribute('style', 'margin-left: 1rem;');
listRight.appendChild(dropButton);
}
listItem.appendChild(listRight);
links.appendChild(listItem);
itemContainer.appendChild(listItem);
if (parsedInvite[2] == 0) {
let itemDropdown = document.createElement('div');
itemDropdown.id = parsedInvite[0] + '_collapse';
itemDropdown.classList.add('collapse');
let dropdownContent = document.createElement('div');
dropdownContent.classList.add('container', 'row', 'align-items-start', 'card-body');
let dropdownLeft = document.createElement('div');
dropdownLeft.classList.add('col');
let leftList = document.createElement('ul');
leftList.classList.add('list-group', 'list-group-flush');
if (typeof(parsedInvite[6]) != 'undefined') {
let createdDate = document.createElement('li');
createdDate.classList.add('list-group-item', 'py-1');
createdDate.textContent = `Created: ${parsedInvite[6]}`;
leftList.appendChild(createdDate);
}
let remainingUses = document.createElement('li');
remainingUses.classList.add('list-group-item', 'py-1');
remainingUses.id = parsedInvite[0] + '_remainingUses';
remainingUses.textContent = `Remaining uses: ${parsedInvite[4]}`;
leftList.appendChild(remainingUses);
dropdownLeft.appendChild(leftList);
dropdownContent.appendChild(dropdownLeft);
let dropdownRight = document.createElement('div');
dropdownRight.id = parsedInvite[0] + '_usersCreated';
dropdownRight.classList.add('col');
if (parsedInvite[5].length != 0) {
let userList = document.createElement('ul');
userList.classList.add('list-group', 'list-group-flush');
userList.innerHTML = '<li class="list-group-item py-1">Users created:</li>';
for (let user of parsedInvite[5]) {
let li = document.createElement('li');
li.classList.add('list-group-item', 'py-1', 'disabled');
let username = document.createElement('div');
username.classList.add('d-flex', 'float-left');
username.textContent = user[0];
li.appendChild(username);
let date = document.createElement('div');
date.classList.add('d-flex', 'float-right');
date.textContent = user[1];
li.appendChild(date);
userList.appendChild(li);
}
dropdownRight.appendChild(userList);
}
dropdownContent.appendChild(dropdownRight);
itemDropdown.appendChild(dropdownContent);
itemContainer.appendChild(itemDropdown);
}
links.appendChild(itemContainer);
}
function updateInvite(parsedInvite) {
let expiry = document.getElementById(parsedInvite[0] + '_expiry');
expiry.textContent = parsedInvite[1];
let remainingUses = document.getElementById(parsedInvite[0] + '_remainingUses');
if (remainingUses) {
remainingUses.textContent = `Remaining uses: ${parsedInvite[4]}`;
}
if (parsedInvite[5].length != 0) {
let usersCreated = document.getElementById(parsedInvite[0] + '_usersCreated');
let dropdownRight = document.createElement('div');
dropdownRight.id = parsedInvite[0] + '_usersCreated';
dropdownRight.classList.add('col');
let userList = document.createElement('ul');
userList.classList.add('list-group', 'list-group-flush');
userList.innerHTML = '<li class="list-group-item py-1">Users created:</li>';
for (let user of parsedInvite[5]) {
let li = document.createElement('li');
li.classList.add('list-group-item', 'py-1', 'disabled');
let username = document.createElement('div');
username.classList.add('d-flex', 'float-left');
username.textContent = user[0];
li.appendChild(username);
let date = document.createElement('div');
date.classList.add('d-flex', 'float-right');
date.textContent = user[1];
li.appendChild(date);
userList.appendChild(li);
}
dropdownRight.appendChild(userList);
usersCreated.replaceWith(dropdownRight);
}
}
// delete from list on page

View File

@ -156,6 +156,9 @@
submitButton.replaceChild(newSpan, oldSpan);
};
document.getElementById('accountForm').onsubmit = function() {
if (document.getElementById('errorMessage')) {
document.getElementById('errorMessage').remove();
}
toggleSpinner();
var send = serializeForm('accountForm');
send['code'] = code;
@ -171,12 +174,18 @@
if (this.readyState == 4) {
toggleSpinner();
var data = this.response;
if ('error' in data) {
var text = document.createTextNode(data['error']);
if ('error' in data || data['success'] == false) {
if (typeof(data['error']) != 'undefined') {
var errorMessage = data['error'];
} else {
var errorMessage = 'Unknown Error';
}
var text = document.createTextNode(errorMessage);
var error = document.createElement('button');
error.classList.add('btn', 'btn-outline-danger');
error.setAttribute('disabled', '');
error.appendChild(text);
error.id = 'errorMessage';
document.getElementById('errorBox').appendChild(error);
} else {
var valid = true

View File

@ -190,7 +190,7 @@ class Jellyfin:
)
def newUser(self, username: str, password: str):
for user in self.getUsers():
for user in self.getUsers(public=False):
if user["Name"] == username:
raise self.UserExistsError
response = requests.post(

View File

@ -42,9 +42,7 @@ def static_proxy(path):
if "html" not in path:
if "admin.js" in path:
return (
render_template("admin.js",
bsVersion=bsVersion,
css_file=css_file),
render_template("admin.js", bsVersion=bsVersion, css_file=css_file),
200,
{"Content-Type": "text/javascript"},
)

View File

@ -20,21 +20,49 @@ from jellyfin_accounts import web_log as log
from jellyfin_accounts.validate_password import PasswordValidator
def checkInvite(code, delete=False):
def format_datetime(dt):
result = dt.strftime(config["email"]["date_format"])
if config.getboolean("email", "use_24h"):
result += f' {dt.strftime("%H:%M")}'
else:
result += f' {dt.strftime("%I:%M %p")}'
return result
def checkInvite(code, used=False, username=None):
current_time = datetime.datetime.now()
invites = dict(data_store.invites)
match = False
for invite in invites:
if (
"remaining-uses" not in invites[invite]
and "no-limit" not in invites[invite]
):
invites[invite]["remaining-uses"] = 1
expiry = datetime.datetime.strptime(
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
)
if current_time >= expiry:
log.debug(f"Housekeeping: Deleting old invite {invite}")
if current_time >= expiry or (
"no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1
):
log.debug(f"Housekeeping: Deleting expired invite {invite}")
del data_store.invites[invite]
elif invite == code:
match = True
if delete:
del data_store.invites[code]
if used:
delete = False
inv = dict(data_store.invites[code])
if "used-by" not in inv:
inv["used-by"] = []
if "remaining-uses" in inv:
if inv["remaining-uses"] == 1:
delete = True
del data_store.invites[code]
elif "no-limit" not in invites[invite]:
inv["remaining-uses"] -= 1
inv["used-by"].append([username, format_datetime(current_time)])
if not delete:
data_store.invites[code] = inv
return match
@ -157,7 +185,7 @@ def newUser():
return jsonify({"error": error})
except:
return jsonify({"error": "Unknown error"})
checkInvite(data["code"], delete=True)
checkInvite(data["code"], used=True, username=data["username"])
if user.status_code == 200:
try:
policy = data_store.user_template
@ -205,6 +233,14 @@ def generateInvite():
)
invite_code = secrets.token_urlsafe(16)
invite = {}
invite["created"] = format_datetime(current_time)
if data["multiple-uses"]:
if data["no-limit"]:
invite["no-limit"] = True
else:
invite["remaining-uses"] = int(data["remaining-uses"])
else:
invite["remaining-uses"] = 1
log.debug(f"Creating new invite: {invite_code}")
valid_till = current_time + delta
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
@ -251,6 +287,16 @@ def getInvites():
"hours": valid_for.seconds // 3600,
"minutes": (valid_for.seconds // 60) % 60,
}
if "created" in invites[code]:
invite["created"] = invites[code]["created"]
if "used-by" in invites[code]:
invite["used-by"] = invites[code]["used-by"]
if "no-limit" in invites[code]:
invite["no-limit"] = invites[code]["no-limit"]
if "remaining-uses" in invites[code]:
invite["remaining-uses"] = invites[code]["remaining-uses"]
else:
invite["remaining-uses"] = 1
if "email" in invites[code]:
invite["email"] = invites[code]["email"]
response["invites"].append(invite)

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "jellyfin-accounts"
version = "0.3.5"
version = "0.3.6"
readme = "README.md"
description = "A simple account management system for Jellyfin"
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]