6 Commits

Author SHA1 Message Date
27169e4e0d 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.
2020-07-10 16:15:17 +01:00
db3b992857 refactor admin.js, add initial ui elements for multi-use invites
multi-use invites will have a set limit of how many times they can be
used. They can also be set to have no limit. An additional menu is
planned for multi use invites to see when they have been used, and by
who.
2020-07-09 23:05:01 +01:00
89c132e92e bump to 0.3.5 2020-07-09 20:34:31 +01:00
7bda2f4141 Fix UI error when 'send to address' option disabled 2020-07-09 20:33:14 +01:00
71f05f2348 Replace jquery ajax in setup.js 2020-07-07 20:10:27 +01:00
94e69ad090 Small CSS tweaks, add days input 2020-07-07 15:30:16 +01:00
12 changed files with 689 additions and 452 deletions

View File

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

View File

@@ -16,6 +16,7 @@ function serializeForm(id) {
case 'password': case 'password':
case 'select-one': case 'select-one':
case 'email': case 'email':
case 'number':
formData[name] = el.value; formData[name] = el.value;
break; break;
}; };

View File

@@ -13,6 +13,7 @@ for (var i = 0; i < authRadios.length; i++) {
checkAuthRadio(); checkAuthRadio();
}); });
}; };
function checkEmailRadio() { function checkEmailRadio() {
document.getElementById('emailNextButton').href = '#page-5'; document.getElementById('emailNextButton').href = '#page-5';
document.getElementById('valBackButton').href = '#page-7'; document.getElementById('valBackButton').href = '#page-7';
@@ -35,6 +36,7 @@ for (var i = 0; i < emailRadios.length; i++) {
checkEmailRadio(); checkEmailRadio();
}); });
}; };
function checkSSL() { function checkSSL() {
var label = document.getElementById('emailSSL_TLSLabel'); var label = document.getElementById('emailSSL_TLSLabel');
if (document.getElementById('emailSSL_TLS').checked) { if (document.getElementById('emailSSL_TLS').checked) {
@@ -101,16 +103,15 @@ document.getElementById('jfTestButton').onclick = function() {
jfData['jfHost'] = document.getElementById('jfHost').value; jfData['jfHost'] = document.getElementById('jfHost').value;
jfData['jfUser'] = document.getElementById('jfUser').value; jfData['jfUser'] = document.getElementById('jfUser').value;
jfData['jfPassword'] = document.getElementById('jfPassword').value; jfData['jfPassword'] = document.getElementById('jfPassword').value;
$.ajax('/testJF', { var req = new XMLHttpRequest();
type : 'POST', req.open("POST", "/testJF", true);
dataType : 'json', req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
contentType : 'application/json', req.responseType = 'json';
data : JSON.stringify(jfData), req.onreadystatechange = function() {
complete: function(response) { if (this.readyState == 4) {
testButton.disabled = false; testButton.disabled = false;
testButton.className = ''; testButton.className = '';
var success = response['responseJSON']['success']; if (this.response['success'] == true) {
if (success == true) {
testButton.classList.add('btn', 'btn-success'); testButton.classList.add('btn', 'btn-success');
testButton.textContent = 'Success'; testButton.textContent = 'Success';
nextButton.classList.remove('disabled'); nextButton.classList.remove('disabled');
@@ -118,9 +119,10 @@ document.getElementById('jfTestButton').onclick = function() {
} else { } else {
testButton.classList.add('btn', 'btn-danger'); testButton.classList.add('btn', 'btn-danger');
testButton.textContent = 'Failed'; testButton.textContent = 'Failed';
} };
} };
}); };
req.send(JSON.stringify(jfData));
}; };
document.getElementById('submitButton').onclick = function() { document.getElementById('submitButton').onclick = function() {
@@ -158,7 +160,7 @@ document.getElementById('submitButton').onclick = function() {
if (document.getElementById('emailDisabledRadio').checked) { if (document.getElementById('emailDisabledRadio').checked) {
config['password_resets']['enabled'] = 'false'; config['password_resets']['enabled'] = 'false';
config['invite_emails']['enabled'] = 'false'; config['invite_emails']['enabled'] = 'false';
} else { } else {
if (document.getElementById('emailSMTPRadio').checked) { if (document.getElementById('emailSMTPRadio').checked) {
if (document.getElementById('emailSSL_TLS').checked) { if (document.getElementById('emailSSL_TLS').checked) {
config['smtp']['encryption'] = 'ssl_tls'; config['smtp']['encryption'] = 'ssl_tls';
@@ -211,24 +213,24 @@ document.getElementById('submitButton').onclick = function() {
config['password_validation']['number'] = document.getElementById('valNumber').value; config['password_validation']['number'] = document.getElementById('valNumber').value;
config['password_validation']['special'] = document.getElementById('valSpecial').value; config['password_validation']['special'] = document.getElementById('valSpecial').value;
} else { } else {
config['password_validation']['enabled'] = 'false'; config['password_validation']['enabled'] = 'false';
}; };
// Page 9: Messages // Page 9: Messages
config['ui']['contact_message'] = document.getElementById('msgContact').value; config['ui']['contact_message'] = document.getElementById('msgContact').value;
config['ui']['help_message'] = document.getElementById('msgHelp').value; config['ui']['help_message'] = document.getElementById('msgHelp').value;
config['ui']['success_message'] = document.getElementById('msgSuccess').value; config['ui']['success_message'] = document.getElementById('msgSuccess').value;
console.log(config); // Send it
$.ajax('/modifyConfig', { var req = new XMLHttpRequest();
type : 'POST', req.open("POST", "/modifyConfig", true);
dataType : 'json', req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
contentType : 'application/json', req.responseType = 'json';
data : JSON.stringify(config), req.onreadystatechange = function() {
complete: function(response) { if (this.readyState == 4) {
submitButton.disabled = false; submitButton.disabled = false;
submitButton.className = ''; submitButton.className = '';
submitButton.classList.add('btn', 'btn-success'); submitButton.classList.add('btn', 'btn-success');
submitButton.textContent = 'Success'; submitButton.textContent = 'Success';
} };
}); };
req.send(JSON.stringify(config));
}; };

View File

@@ -31,21 +31,24 @@
return ""; return "";
}; };
{% if bs5 %} {% if bs5 %}
var bsVersion = 5; const bsVersion = 5;
{% else %} {% else %}
var bsVersion = 4; const bsVersion = 4;
{% endif %} {% endif %}
console.log('create');
var css = document.createElement('link'); var css = document.createElement('link');
css.setAttribute('rel', 'stylesheet'); css.setAttribute('rel', 'stylesheet');
css.setAttribute('type', 'text/css'); css.setAttribute('type', 'text/css');
var cssCookie = getCookie("css"); var cssCookie = getCookie("css");
if (cssCookie.includes('bs' + bsVersion)) { if (cssCookie.includes('bs' + bsVersion)) {
console.log('href');
css.setAttribute('href', cssCookie); css.setAttribute('href', cssCookie);
} else { } else {
console.log('href');
css.setAttribute('href', '{{ css_file }}'); css.setAttribute('href', '{{ css_file }}');
}; };
console.log('append');
document.head.appendChild(css); document.head.appendChild(css);
// document.querySelectorAll('link[rel="stylesheet"][type="text/css"]')[0].href = cssCookie;
</script> </script>
{% if not bs5 %} {% if not bs5 %}
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
@@ -91,16 +94,31 @@
height: 1rem; height: 1rem;
border-radius: 50%; border-radius: 50%;
z-index: 5000;*/ 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); -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); -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 */
} }
.smooth-transition { .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); -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); -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> </style>
<title>Admin</title> <title>Admin</title>
@@ -234,29 +252,66 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header">Generate Invite</div> <div class="card-header">Generate Invite</div>
<div class="card-body"> <div class="card-body">
<form action="#" method="POST" id="inviteForm"> <form action="#" method="POST" id="inviteForm" class="container">
<div class="form-group"> <div class="row align-items-start">
<label for="hours">Hours</label> <div class="col">
<select class="form-control" id="hours" name="hours"> <div class="form-group">
</select> <label for="days">Days</label>
</div> <select class="form-control form-select" id="days" name="days">
<div class="form-group"> </select>
<label for="minutes">Minutes</label> </div>
<select class="form-control" id="minutes" name="minutes"> <div class="form-group">
</select> <label for="hours">Hours</label>
</div> <select class="form-control form-select" id="hours" name="hours">
{% if email_enabled %} </select>
<div class="form-group"> </div>
<label for="send_to_address">Send invite to address</label> <div class="form-group">
<div class="input-group"> <label for="minutes">Minutes</label>
<div class="input-group-text"> <select class="form-control form-select" id="minutes" name="minutes">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled"> </select>
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div> </div>
</div> </div>
{% endif %} <div class="col">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">Generate</button> <div class="form-group">
<label for="multiUseCount">
Multiple uses
</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" 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; if (this.checked) { document.getElementById('noLimitWarning').style = 'display: block;' } else { document.getElementById('noLimitWarning').style = 'display: none;'; }">
<label class="form-check-label" for="noUseLimit">
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">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input" type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex float-right">
<button type="submit" id="generateSubmit" class="btn btn-primary" style="margin-top: 1rem;">
Generate
</button>
</div>
</div>
</div>
</form> </form>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -42,9 +42,7 @@ def static_proxy(path):
if "html" not in path: if "html" not in path:
if "admin.js" in path: if "admin.js" in path:
return ( return (
render_template("admin.js", render_template("admin.js", bsVersion=bsVersion, css_file=css_file),
bsVersion=bsVersion,
css_file=css_file),
200, 200,
{"Content-Type": "text/javascript"}, {"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 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() current_time = datetime.datetime.now()
invites = dict(data_store.invites) invites = dict(data_store.invites)
match = False match = False
for invite in invites: 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( expiry = datetime.datetime.strptime(
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f" invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
) )
if current_time >= expiry: if current_time >= expiry or (
log.debug(f"Housekeeping: Deleting old invite {invite}") "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] del data_store.invites[invite]
elif invite == code: elif invite == code:
match = True match = True
if delete: if used:
del data_store.invites[code] 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 return match
@@ -157,7 +185,7 @@ def newUser():
return jsonify({"error": error}) return jsonify({"error": error})
except: except:
return jsonify({"error": "Unknown error"}) return jsonify({"error": "Unknown error"})
checkInvite(data["code"], delete=True) checkInvite(data["code"], used=True, username=data["username"])
if user.status_code == 200: if user.status_code == 200:
try: try:
policy = data_store.user_template policy = data_store.user_template
@@ -200,9 +228,19 @@ def newUser():
def generateInvite(): def generateInvite():
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
data = request.get_json() data = request.get_json()
delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"])) delta = datetime.timedelta(
days=int(data["days"]), hours=int(data["hours"]), minutes=int(data["minutes"])
)
invite_code = secrets.token_urlsafe(16) invite_code = secrets.token_urlsafe(16)
invite = {} 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}") log.debug(f"Creating new invite: {invite_code}")
valid_till = current_time + delta valid_till = current_time + delta
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f") invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
@@ -245,9 +283,20 @@ def getInvites():
valid_for = expiry - current_time valid_for = expiry - current_time
invite = { invite = {
"code": code, "code": code,
"days": valid_for.days,
"hours": valid_for.seconds // 3600, "hours": valid_for.seconds // 3600,
"minutes": (valid_for.seconds // 60) % 60, "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]: if "email" in invites[code]:
invite["email"] = invites[code]["email"] invite["email"] = invites[code]["email"]
response["invites"].append(invite) response["invites"].append(invite)

View File

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

View File

@@ -120,6 +120,10 @@ $list-group-action-active-bg: $jf-blue-focus;
color: $jf-text-bold; color: $jf-text-bold;
} }
.icon-button {
color: $text-muted;
}
.text-bright { .text-bright {
color: $jf-text-bold; color: $jf-text-bold;
} }

View File

@@ -120,6 +120,10 @@ $list-group-action-active-bg: $jf-blue-focus;
color: $jf-text-bold; color: $jf-text-bold;
} }
.icon-button:active {
color: $text-muted;
}
.text-bright { .text-bright {
color: $jf-text-bold; color: $jf-text-bold;
} }