1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-29 12:30:11 +00:00

Compare commits

..

4 Commits

Author SHA1 Message Date
b6ceee508c
Fix bug with invites in webui, add profile selector
invite codes starting with a digit don't work with the webui, so
GenerateInvite regenerates uuids until theres one that doesn't.
2020-09-22 00:34:11 +01:00
32b8ed4aa2
rewrite* most web ui code in typescript
i wanted to split up the web ui components into multiple files, and
figured it'd be a good chance to try out typescript. run make typescript
to compile everything in ts/ and put it in data/static/.

This is less of a rewrite and more of a refactoring, most of it still
works the same but bits have been cleaned up too.

Remaining javascript found in setup.js and form.html
2020-09-21 22:06:27 +01:00
73886fc037
rewrite accounts.js in typescript
slight refactor too.
2020-09-20 14:48:17 +01:00
c4acb43cb8
Initial features for move to profiles
user templates will become profiles. You will be able to make multiple,
and assign them  to invites individually. This commit migrates the
separate template files into one profile entry called "Default", and
lets you select them on invites. No way to create profiles has been
added yet.
2020-09-20 11:21:04 +01:00
23 changed files with 1674 additions and 1635 deletions

2
.gitignore vendored
View File

@ -5,6 +5,8 @@ scss/*.css*
scss/bs4/*.css* scss/bs4/*.css*
scss/bs5/*.css* scss/bs5/*.css*
data/static/*.css data/static/*.css
data/static/*.js
!data/static/setup.js
data/config-base.json data/config-base.json
data/config-default.ini data/config-default.ini
data/*.html data/*.html

View File

@ -1,40 +1,44 @@
configuration: configuration:
echo "Fixing config-base" $(info Fixing config-base)
python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json python3 config/fixconfig.py -i config/config-base.json -o data/config-base.json
echo "Generating config-default.ini" $(info Generating config-default.ini)
python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini --version git python3 config/generate_ini.py -i config/config-base.json -o data/config-default.ini --version git
sass: sass:
echo "Getting libsass" $(info Getting libsass)
python3 -m pip install libsass python3 -m pip install libsass
echo "Getting node dependencies" $(info Getting node dependencies)
python3 scss/get_node_deps.py python3 scss/get_node_deps.py
echo "Compiling sass" $(info Compiling sass)
python3 scss/compile.py python3 scss/compile.py
sass-headless: sass-headless:
echo "Getting libsass" $(info Getting libsass)
python3 -m pip install libsass python3 -m pip install libsass
echo "Getting node dependencies" $(info Getting node dependencies)
python3 scss/get_node_deps.py python3 scss/get_node_deps.py
echo "Compiling sass" $(info Compiling sass)
python3 scss/compile.py -y python3 scss/compile.py -y
mail-headless: mail-headless:
echo "Generating email html" $(info Generating email html)
python3 mail/generate.py -y python3 mail/generate.py -y
mail: mail:
echo "Generating email html" $(info Generating email html)
python3 mail/generate.py python3 mail/generate.py
typescript:
$(info Compiling typescript)
-npx tsc -p ts/
version: version:
python3 version.py auto version.go python3 version.py auto version.go
compile: compile:
echo "Downloading deps" $(info Downloading deps)
go mod download go mod download
echo "Building" $(info Building)
mkdir -p build mkdir -p build
CGO_ENABLED=0 go build -o build/jfa-go *.go CGO_ENABLED=0 go build -o build/jfa-go *.go
@ -42,14 +46,14 @@ compress:
upx --lzma build/jfa-go upx --lzma build/jfa-go
copy: copy:
echo "Copying data" $(info Copying data)
cp -r data build/ cp -r data build/
install: install:
cp -r build $(DESTDIR)/jfa-go cp -r build $(DESTDIR)/jfa-go
all: configuration sass mail version compile copy all: configuration sass mail version typescript compile copy
headless: configuration sass-headless mail-headless version compile copy headless: configuration sass-headless mail-headless version typescript compile copy

79
api.go
View File

@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@ -230,6 +231,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
} }
} }
} }
app.jf.cacheExpiry = time.Now()
} }
func (app *appContext) NewUser(gc *gin.Context) { func (app *appContext) NewUser(gc *gin.Context) {
@ -293,20 +295,28 @@ func (app *appContext) NewUser(gc *gin.Context) {
if user["Id"] != nil { if user["Id"] != nil {
id = user["Id"].(string) id = user["Id"].(string)
} }
if len(app.storage.policy) != 0 { if invite.Profile != "" {
status, err = app.jf.setPolicy(id, app.storage.policy) profile, ok := app.storage.profiles[invite.Profile]
if !ok {
profile = app.storage.profiles["Default"]
}
if len(profile.Policy) != 0 {
app.debug.Printf("Applying policy from profile \"%s\"", invite.Profile)
status, err = app.jf.setPolicy(id, profile.Policy)
if !(status == 200 || status == 204) { if !(status == 200 || status == 204) {
app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status) app.err.Printf("%s: Failed to set user policy: Code %d", req.Code, status)
} }
} }
if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 { if len(profile.Configuration) != 0 && len(profile.Displayprefs) != 0 {
status, err = app.jf.setConfiguration(id, app.storage.configuration) app.debug.Printf("Applying homescreen from profile \"%s\"", invite.Profile)
status, err = app.jf.setConfiguration(id, profile.Configuration)
if (status == 200 || status == 204) && err == nil { if (status == 200 || status == 204) && err == nil {
status, err = app.jf.setDisplayPreferences(id, app.storage.displayprefs) status, err = app.jf.setDisplayPreferences(id, profile.Displayprefs)
} else { } else {
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status) app.err.Printf("%s: Failed to set configuration template: Code %d", req.Code, status)
} }
} }
}
if app.config.Section("password_resets").Key("enabled").MustBool(false) { if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email app.storage.emails[id] = req.Email
app.storage.storeEmails() app.storage.storeEmails()
@ -379,6 +389,7 @@ type generateInviteReq struct {
MultipleUses bool `json:"multiple-uses"` MultipleUses bool `json:"multiple-uses"`
NoLimit bool `json:"no-limit"` NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"` RemainingUses int `json:"remaining-uses"`
Profile string `json:"profile"`
} }
func (app *appContext) GenerateInvite(gc *gin.Context) { func (app *appContext) GenerateInvite(gc *gin.Context) {
@ -389,7 +400,13 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
current_time := time.Now() current_time := time.Now()
valid_till := current_time.AddDate(0, 0, req.Days) valid_till := current_time.AddDate(0, 0, req.Days)
valid_till = valid_till.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes)) valid_till = valid_till.Add(time.Hour*time.Duration(req.Hours) + time.Minute*time.Duration(req.Minutes))
// make sure code doesn't begin with number
invite_code := shortuuid.New() invite_code := shortuuid.New()
_, err := strconv.Atoi(string(invite_code[0]))
for err == nil {
invite_code = shortuuid.New()
_, err = strconv.Atoi(string(invite_code[0]))
}
var invite Invite var invite Invite
invite.Created = current_time invite.Created = current_time
if req.MultipleUses { if req.MultipleUses {
@ -418,11 +435,40 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email) app.info.Printf("%s: Sent invite email to %s", invite_code, req.Email)
} }
} }
if req.Profile != "" {
if _, ok := app.storage.profiles[req.Profile]; ok {
invite.Profile = req.Profile
} else {
invite.Profile = "Default"
}
}
app.storage.invites[invite_code] = invite app.storage.invites[invite_code] = invite
app.storage.storeInvites() app.storage.storeInvites()
gc.JSON(200, map[string]bool{"success": true}) gc.JSON(200, map[string]bool{"success": true})
} }
type profileReq struct {
Invite string `json:"invite"`
Profile string `json:"profile"`
}
func (app *appContext) SetProfile(gc *gin.Context) {
var req profileReq
gc.BindJSON(&req)
app.debug.Printf("%s: Setting profile to \"%s\"", req.Invite, req.Profile)
// "" means "Don't apply profile"
if _, ok := app.storage.profiles[req.Profile]; !ok && req.Profile != "" {
app.err.Printf("%s: Profile \"%s\" not found", req.Invite, req.Profile)
respond(500, "Profile not found", gc)
return
}
inv := app.storage.invites[req.Invite]
inv.Profile = req.Profile
app.storage.invites[req.Invite] = inv
app.storage.storeInvites()
gc.JSON(200, map[string]bool{"success": true})
}
func (app *appContext) GetInvites(gc *gin.Context) { func (app *appContext) GetInvites(gc *gin.Context) {
app.debug.Println("Invites requested") app.debug.Println("Invites requested")
current_time := time.Now() current_time := time.Now()
@ -431,12 +477,14 @@ func (app *appContext) GetInvites(gc *gin.Context) {
var invites []map[string]interface{} var invites []map[string]interface{}
for code, inv := range app.storage.invites { for code, inv := range app.storage.invites {
_, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, current_time) _, _, days, hours, minutes, _ := timeDiff(inv.ValidTill, current_time)
invite := make(map[string]interface{}) invite := map[string]interface{}{
invite["code"] = code "code": code,
invite["days"] = days "days": days,
invite["hours"] = hours "hours": hours,
invite["minutes"] = minutes "minutes": minutes,
invite["created"] = app.formatDatetime(inv.Created) "created": app.formatDatetime(inv.Created),
"profile": inv.Profile,
}
if len(inv.UsedBy) != 0 { if len(inv.UsedBy) != 0 {
invite["used-by"] = inv.UsedBy invite["used-by"] = inv.UsedBy
} }
@ -470,7 +518,14 @@ func (app *appContext) GetInvites(gc *gin.Context) {
} }
invites = append(invites, invite) invites = append(invites, invite)
} }
resp := map[string][]map[string]interface{}{ profiles := make([]string, len(app.storage.profiles))
i := 0
for p := range app.storage.profiles {
profiles[i] = p
i++
}
resp := map[string]interface{}{
"profiles": profiles,
"invites": invites, "invites": invites,
} }
gc.JSON(200, resp) gc.JSON(200, resp)

View File

@ -48,7 +48,7 @@ func (app *appContext) loadConfig() error {
// } // }
key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json")))) key.SetValue(key.MustString(filepath.Join(app.data_path, (key.Name() + ".json"))))
} }
for _, key := range []string{"user_configuration", "user_displayprefs", "ombi_template"} { for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template"} {
// if app.config.Section("files").Key(key).MustString("") == "" { // if app.config.Section("files").Key(key).MustString("") == "" {
// key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json"))) // key.SetValue(filepath.Join(app.data_path, (key.Name() + ".json")))
// } // }

View File

@ -626,6 +626,14 @@
"value": "", "value": "",
"description": "Location of stored displayPreferences template (also used for homescreen layout) (json)" "description": "Location of stored displayPreferences template (also used for homescreen layout) (json)"
}, },
"user_profiles": {
"name": "User Profiles",
"required": false,
"requires_restart": true,
"type": "text",
"value": "",
"description": "Location of stored user profiles (encompasses template and homescreen) (json)"
},
"custom_css": { "custom_css": {
"name": "Custom CSS", "name": "Custom CSS",
"required": false, "required": false,

View File

@ -1,356 +0,0 @@
document.getElementById('selectAll').onclick = function() {
const checkboxes = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
for (check of checkboxes) {
check.checked = this.checked;
}
checkCheckboxes();
};
function checkCheckboxes() {
const defaultsButton = document.getElementById('accountsTabSetDefaults');
const deleteButton = document.getElementById('accountsTabDelete');
const checkboxes = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
let checked = 0;
for (check of checkboxes) {
if (check.checked) {
checked++;
}
}
if (checked == 0) {
defaultsButton.classList.add('unfocused');
deleteButton.classList.add('unfocused');
} else {
if (defaultsButton.classList.contains('unfocused')) {
defaultsButton.classList.remove('unfocused');
}
if (deleteButton.classList.contains('unfocused')) {
deleteButton.classList.remove('unfocused');
}
if (checked == 1) {
deleteButton.textContent = 'Delete User';
} else {
deleteButton.textContent = 'Delete Users';
}
}
}
document.getElementById('deleteModalNotify').onclick = function() {
const textbox = document.getElementById('deleteModalReasonBox');
if (this.checked && textbox.classList.contains('unfocused')) {
textbox.classList.remove('unfocused');
} else if (!this.checked) {
textbox.classList.add('unfocused');
}
};
document.getElementById('accountsTabDelete').onclick = function() {
const deleteButton = this;
let selected = [];
const checkboxes = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
for (check of checkboxes) {
if (check.checked) {
selected.push(check.id.replace('select_', ''));
}
}
let title = " user";
let msg = "Notify user";
if (selected.length > 1) {
title += "s";
msg += "s";
}
title = "Delete " + selected.length + title;
msg += " of account deletion";
document.getElementById('deleteModalTitle').textContent = title;
document.getElementById('deleteModalNotify').checked = false;
document.getElementById('deleteModalNotifyLabel').textContent = msg;
document.getElementById('deleteModalReason').value = '';
document.getElementById('deleteModalReasonBox').classList.add('unfocused');
document.getElementById('deleteModalSend').textContent = 'Delete';
document.getElementById('deleteModalSend').onclick = function() {
const button = this;
const send = {
'users': selected,
'notify': document.getElementById('deleteModalNotify').checked,
'reason': document.getElementById('deleteModalReason').value
};
let req = new XMLHttpRequest();
req.open("POST", "/deleteUser", true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 500) {
if ("error" in req.response) {
button.textContent = 'Failed';
} else {
button.textContent = 'Partial fail (check console)';
console.log(req.response);
}
setTimeout(function() {
deleteModal.hide();
deleteButton.classList.add('unfocused');
}, 4000);
} else {
deleteButton.classList.add('unfocused');
deleteModal.hide();
}
populateUsers();
checkCheckboxes();
}
};
req.send(JSON.stringify(send));
};
deleteModal.show();
}
var jfUsers = [];
function validEmail(email) {
const re = /\S+@\S+\.\S+/;
return re.test(email);
}
function changeEmail(icon, id) {
const iconContent = icon.outerHTML;
icon.setAttribute("class", "");
const entry = icon.nextElementSibling;
const ogEmail = entry.value;
entry.readOnly = false;
entry.classList.remove('form-control-plaintext');
entry.classList.add('form-control');
if (entry.value == "") {
entry.placeholder = 'Address';
}
const tick = document.createElement('i');
tick.classList.add("fa", "fa-check", "d-inline-block", "icon-button", "text-success");
tick.setAttribute('style', 'margin-left: 0.5rem; margin-right: 0.5rem;');
tick.onclick = function() {
const newEmail = entry.value;
if (!validEmail(newEmail) || newEmail == ogEmail) {
return
}
cross.remove();
this.outerHTML = `
<div class="spinner-border spinner-border-sm" role="status" style="width: 1rem; height: 1rem; margin-left: 0.5rem;">
<span class="sr-only">Saving...</span>
</div>`;
//this.remove();
let send = {};
send[id] = newEmail;
let req = new XMLHttpRequest();
req.open("POST", "/modifyEmails", true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
entry.nextElementSibling.remove();
} else {
entry.value = ogEmail;
}
}
};
req.send(JSON.stringify(send));
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
};
const cross = document.createElement('i');
cross.classList.add("fa", "fa-close", "d-inline-block", "icon-button", "text-danger");
cross.onclick = function() {
tick.remove();
this.remove();
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
entry.value = ogEmail;
};
icon.parentNode.appendChild(tick);
icon.parentNode.appendChild(cross);
}
function populateUsers() {
const acList = document.getElementById('accountsList');
acList.innerHTML = `
<div class="d-flex align-items-center">
<strong>Getting Users...</strong>
<div class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
</div>`;
acList.parentNode.querySelector('thead').classList.add('unfocused');
const accountsList = document.createElement('tbody');
accountsList.id = 'accountsList';
const generateEmail = function(id, name, email) {
let entry = document.createElement('div');
// entry.classList.add('py-1');
entry.id = 'email_' + id;
let emailValue = email;
if (email === undefined) {
emailValue = "";
}
entry.innerHTML = `
<i class="fa fa-edit d-inline-block icon-button" style="margin-right: 2%;" onclick="changeEmail(this, '${id}')"></i>
<input type="email" class="form-control-plaintext form-control-sm text-muted d-inline-block addressText" id="address_${id}" style="width: auto;" value="${emailValue}" readonly>
`;
return entry.outerHTML
};
const template = function(id, username, email, lastActive, admin) {
let isAdmin = "No";
if (admin) {
isAdmin = "Yes";
}
let fci = "form-check-input";
if (bsVersion != 5) {
fci = "";
}
return `
<td nowrap="nowrap" class="align-middle" scope="row"><input class="${fci}" type="checkbox" value="" id="select_${id}" onclick="checkCheckboxes();"></td>
<td nowrap="nowrap" class="align-middle">${username}</td>
<td nowrap="nowrap" class="align-middle">${generateEmail(id, name, email)}</td>
<td nowrap="nowrap" class="align-middle">${lastActive}</td>
<td nowrap="nowrap" class="align-middle">${isAdmin}</td>
`;
};
let req = new XMLHttpRequest();
req.responseType = 'json';
req.open("GET", "/getUsers", true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 200) {
jfUsers = req.response['users'];
for (user of jfUsers) {
let tr = document.createElement('tr');
tr.innerHTML = template(user['id'], user['name'], user['email'], user['last_active'], user['admin']);
accountsList.appendChild(tr);
}
const header = acList.parentNode.querySelector('thead');
if (header.classList.contains('unfocused')) {
header.classList.remove('unfocused');
}
acList.replaceWith(accountsList);
}
}
};
req.send();
}
document.getElementById('selectAll').checked = false;
document.getElementById('accountsTabSetDefaults').onclick = function() {
const checkboxes = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
let userIDs = [];
for (check of checkboxes) {
if (check.checked) {
userIDs.push(check.id.replace('select_', ''));
}
}
if (userIDs.length == 0) {
return;
}
populateRadios();
let userstring = 'user';
if (userIDs.length > 1) {
userstring += 's';
}
document.getElementById('defaultsTitle').textContent = `Apply settings to ${userIDs.length} ${userstring}`;
document.getElementById('userDefaultsDescription').textContent = `
Create an account and configure it to your liking, then choose it from below to apply to your selected users.`;
document.getElementById('storeHomescreenLabel').textContent = `Apply homescreen layout`;
if (document.getElementById('defaultsSourceSection').classList.contains('unfocused')) {
document.getElementById('defaultsSourceSection').classList.remove('unfocused');
}
document.getElementById('defaultsSource').value = 'userTemplate';
document.getElementById('defaultUserRadios').classList.add('unfocused');
document.getElementById('storeDefaults').onclick = function() {
storeDefaults(userIDs);
};
userDefaultsModal.show();
};
document.getElementById('defaultsSource').addEventListener('change', function() {
const radios = document.getElementById('defaultUserRadios');
if (this.value == 'userTemplate') {
radios.classList.add('unfocused');
} else if (radios.classList.contains('unfocused')) {
radios.classList.remove('unfocused');
}
})
document.getElementById('newUserCreate').onclick = function() {
const ogText = this.textContent;
this.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating...`;
const email = document.getElementById('newUserEmail').value;
var username = email;
if (document.getElementById('newUserName') != null) {
username = document.getElementById('newUserName').value;
}
const password = document.getElementById('newUserPassword').value;
if (!validEmail(email) && email != "") {
return;
}
const send = {
'username': username,
'password': password,
'email': email
}
let req = new XMLHttpRequest()
req.open("POST", "/newUserAdmin", true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
const button = this;
req.onreadystatechange = function() {
if (this.readyState == 4) {
button.textContent = ogText;
if (this.status == 200) {
if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
}
button.classList.add('btn-success');
button.textContent = 'Success';
setTimeout(function() {
if (button.classList.contains('btn-success')) {
button.classList.remove('btn-success');
}
button.classList.add('btn-primary');
button.textContent = ogText;
newUserModal.hide();
}, 1000);
} else {
if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
}
button.classList.add('btn-danger');
if ("error" in req.response) {
button.textContent = req.response["error"];
} else {
button.textContent = 'Failed';
}
setTimeout(function() {
if (button.classList.contains('btn-danger')) {
button.classList.remove('btn-danger');
}
button.classList.add('btn-primary');
button.textContent = ogText;
}, 2000);
}
}
};
req.send(JSON.stringify(send));
}
document.getElementById('accountsTabAddUser').onclick = function() {
document.getElementById('newUserEmail').value = '';
document.getElementById('newUserPassword').value = '';
if (document.getElementById('newUserName') != null) {
document.getElementById('newUserName').value = '';
}
newUserModal.show();
};

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +0,0 @@
function serializeForm(id) {
var form = document.getElementById(id);
var formData = {};
for (var i = 0; i < form.elements.length; i++) {
var el = form.elements[i];
if (el.type != 'submit') {
var name = el.name;
if (name == '') {
name = el.id;
};
switch (el.type) {
case 'checkbox':
formData[name] = el.checked;
break;
case 'text':
case 'password':
case 'email':
case 'number':
formData[name] = el.value;
break;
case 'select-one':
let val = el.value;
if (!isNaN(val)) {
val = parseInt(val)
}
formData[name] = val;
break;
};
};
};
return formData;
};

View File

@ -31,9 +31,9 @@
return ""; return "";
} }
{{ if .bs5 }} {{ if .bs5 }}
const bsVersion = 5; var bsVersion = 5;
{{ else }} {{ else }}
const bsVersion = 4; var bsVersion = 4;
{{ end }} {{ end }}
var cssFile = "{{ .cssFile }}"; var cssFile = "{{ .cssFile }}";
var css = document.createElement('link'); var css = document.createElement('link');
@ -52,8 +52,6 @@
} }
css.setAttribute('href', cssFile); css.setAttribute('href', cssFile);
document.head.appendChild(css); document.head.appendChild(css);
// store whether ombi is enabled, 1 or 0.
var ombiEnabled = {{ .ombiEnabled }}
</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>
@ -315,7 +313,7 @@
<button type="button" class="btn btn-primary" id="openSettings"> <button type="button" class="btn btn-primary" id="openSettings">
Settings <i class="fa fa-cog"></i> Settings <i class="fa fa-cog"></i>
</button> </button>
<button type="button" class="btn btn-danger" id="logoutButton" style="display: none;"> <button type="button" class="btn btn-danger unfocused" id="logoutButton">
Logout <i class="fa fa-sign-out"></i> Logout <i class="fa fa-sign-out"></i>
</button> </button>
</div> </div>
@ -367,6 +365,11 @@
</label> </label>
<div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div> <div id="noLimitWarning" class="form-text" style="display: none;">Warning: Unlimited usage invites pose a risk if published online.</div>
</div> </div>
<div class="form-group" style="margin-bottom: 1rem;">
<label for="inviteProfile">Account creation profile</label>
<select class="form-control form-select" id="inviteProfile" name="profile">
</select>
</div>
{{ if .email_enabled }} {{ if .email_enabled }}
<div class="form-group"> <div class="form-group">
<label for="send_to_address">Send invite to address</label> <label for="send_to_address">Send invite to address</label>
@ -431,80 +434,24 @@
</div> </div>
<script src="serialize.js"></script> <script src="serialize.js"></script>
<script> <script>
{{ if .bs5 }}
function createModal(id, find = false) {
let modal;
if (find) {
modal = bootstrap.Modal.getInstance(document.getElementById(id));
} else {
modal = new bootstrap.Modal(document.getElementById(id));
}
document.getElementById(id).addEventListener('shown.bs.modal', function () {
document.body.classList.add("modal-open");
});
return {
modal: modal,
show: function() {
let temp = this.modal.show();
return temp
},
hide: function() { return this.modal.hide(); }
};
}
{{ else }}
let send_to_addess_enabled = document.getElementById('send_to_address_enabled');
if (typeof(send_to_address_enabled) != 'undefined') {
send_to_address_enabled.classList.remove('form-check-input');
}
let multiUseEnabled = document.getElementById('multiUseEnabled');
if (typeof(multiUseEnabled) != 'undefined') {
multiUseEnabled.classList.remove('form-check-input');
}
function createModal(id, find = false) {
$('#' + id).on('shown.bs.modal', function () {
document.body.classList.add("modal-open");
});
return {
show: function() {
let temp = $('#' + id).modal('show');
return temp
},
hide: function() {
return $('#' + id).modal('hide');
}
};
}
{{ end }}
function triggerTooltips() {
{{ if .bs5 }}
document.getElementById('settingsMenu').addEventListener('shown.bs.modal', function() {
{{ else }}
$('#settingsMenu').on('shown.bs.modal', function() {
{{ end }}
// Hacky way to ensure anything dependent on checkbox state is disabled if necessary by just clicking them
let checkboxes = document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]');
for (checkbox of checkboxes) {
checkbox.click();
checkbox.click();
}
let tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map(function(el) {
{{ if .bs5 }}
return new bootstrap.Tooltip(el);
{{ else }}
return $(el).tooltip();
{{ end }}
});
});
}
{{ if .notifications }} {{ if .notifications }}
const notifications_enabled = true; var notifications_enabled = true;
{{ else }} {{ else }}
const notifications_enabled = false; var notifications_enabled = false;
{{ end }} {{ end }}
</script> </script>
{{ if .bs5 }}
<script src="bs5.js"></script>
{{ else }}
<script src="bs4.js"></script>
{{ end }}
<script src="animation.js"></script>
<script src="accounts.js"></script> <script src="accounts.js"></script>
<script src="invites.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<script src="settings.js"></script>
{{ if .ombiEnabled }}
<script src="ombi.js"></script>
{{ end }}
</body> </body>
</html> </html>

21
main.go
View File

@ -328,6 +328,26 @@ func start(asDaemon, firstCall bool) {
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String() app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
app.storage.loadDisplayprefs() app.storage.loadDisplayprefs()
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()
if !(len(app.storage.policy) == 0 && len(app.storage.configuration) == 0 && len(app.storage.displayprefs) == 0) {
app.info.Println("Migrating user template files to new profile format")
app.storage.migrateToProfile()
for _, path := range [3]string{app.storage.policy_path, app.storage.configuration_path, app.storage.displayprefs_path} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
dir, fname := filepath.Split(path)
newFname := strings.Replace(fname, ".json", ".old.json", 1)
err := os.Rename(path, filepath.Join(dir, newFname))
if err != nil {
app.err.Fatalf("Failed to rename %s: %s", fname, err)
}
}
}
app.info.Println("In case of a problem, your original files have been renamed to <file>.old.json")
app.storage.storeProfiles()
}
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String() app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String()
app.storage.loadOmbiTemplate() app.storage.loadOmbiTemplate()
@ -438,6 +458,7 @@ func start(asDaemon, firstCall bool) {
api.POST("/newUserAdmin", app.NewUserAdmin) api.POST("/newUserAdmin", app.NewUserAdmin)
api.POST("/generateInvite", app.GenerateInvite) api.POST("/generateInvite", app.GenerateInvite)
api.GET("/getInvites", app.GetInvites) api.GET("/getInvites", app.GetInvites)
api.POST("/setProfile", app.SetProfile)
api.POST("/setNotify", app.SetNotify) api.POST("/setNotify", app.SetNotify)
api.POST("/deleteInvite", app.DeleteInvite) api.POST("/deleteInvite", app.DeleteInvite)
api.POST("/deleteUser", app.DeleteUser) api.POST("/deleteUser", app.DeleteUser)

23
package-lock.json generated
View File

@ -49,6 +49,19 @@
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ=="
}, },
"@types/jquery": {
"version": "3.5.1",
"resolved": "https://registry.npm.taobao.org/@types/jquery/download/@types/jquery-3.5.1.tgz",
"integrity": "sha1-zrsFes9QccQOQ58w6EDFejDUBsM=",
"requires": {
"@types/sizzle": "*"
}
},
"@types/sizzle": {
"version": "2.3.2",
"resolved": "https://registry.npm.taobao.org/@types/sizzle/download/@types/sizzle-2.3.2.tgz",
"integrity": "sha1-qBG4wY4rq6t9VCszZYh64uTZ3kc="
},
"abbrev": { "abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -1845,6 +1858,11 @@
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
}, },
"popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npm.taobao.org/popper.js/download/popper.js-1.16.1.tgz",
"integrity": "sha1-KiI8s9x7YhPXQOQDcr5A3kPmWxs="
},
"postcss": { "postcss": {
"version": "7.0.32", "version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
@ -2201,6 +2219,11 @@
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
}, },
"typescript": {
"version": "4.0.3",
"resolved": "https://registry.npm.taobao.org/typescript/download/typescript-4.0.3.tgz?cache=0&sync_timestamp=1600584904815&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Ftypescript%2Fdownload%2Ftypescript-4.0.3.tgz",
"integrity": "sha1-FTu9Ro7wdyXB35x36LRT+NNqu6U="
},
"uglify-js": { "uglify-js": {
"version": "3.4.10", "version": "3.4.10",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz",

View File

@ -17,12 +17,14 @@
}, },
"homepage": "https://github.com/hrfee/jellyfin-accounts#readme", "homepage": "https://github.com/hrfee/jellyfin-accounts#readme",
"dependencies": { "dependencies": {
"@types/jquery": "^3.5.1",
"autoprefixer": "^9.8.5", "autoprefixer": "^9.8.5",
"bootstrap": "^5.0.0-alpha1", "bootstrap": "^5.0.0-alpha1",
"bootstrap4": "npm:bootstrap@^4.5.0", "bootstrap4": "npm:bootstrap@^4.5.0",
"clean-css-cli": "^4.3.0", "clean-css-cli": "^4.3.0",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"mjml": "^4.6.3", "mjml": "^4.6.3",
"postcss-cli": "^7.1.1" "postcss-cli": "^7.1.1",
"typescript": "^4.0.3"
} }
} }

View File

@ -8,13 +8,20 @@ import (
type Storage struct { type Storage struct {
timePattern string timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path string invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path string
invites Invites invites Invites
profiles map[string]Profile
emails, policy, configuration, displayprefs, ombi_template map[string]interface{} emails, policy, configuration, displayprefs, ombi_template map[string]interface{}
} }
// timePattern: %Y-%m-%dT%H:%M:%S.%f // timePattern: %Y-%m-%dT%H:%M:%S.%f
type Profile struct {
Policy map[string]interface{} `json:"policy"`
Configuration map[string]interface{} `json:"configuration"`
Displayprefs map[string]interface{} `json:"displayprefs"`
}
type Invite struct { type Invite struct {
Created time.Time `json:"created"` Created time.Time `json:"created"`
NoLimit bool `json:"no-limit"` NoLimit bool `json:"no-limit"`
@ -23,6 +30,7 @@ type Invite struct {
Email string `json:"email"` Email string `json:"email"`
UsedBy [][]string `json:"used-by"` UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
} }
type Invites map[string]Invite type Invites map[string]Invite
@ -75,6 +83,27 @@ func (st *Storage) storeOmbiTemplate() error {
return storeJSON(st.ombi_path, st.ombi_template) return storeJSON(st.ombi_path, st.ombi_template)
} }
func (st *Storage) loadProfiles() error {
return loadJSON(st.profiles_path, &st.profiles)
}
func (st *Storage) storeProfiles() error {
return storeJSON(st.profiles_path, st.profiles)
}
func (st *Storage) migrateToProfile() error {
st.loadPolicy()
st.loadConfiguration()
st.loadDisplayprefs()
st.loadProfiles()
st.profiles["Default"] = Profile{
Policy: st.policy,
Configuration: st.configuration,
Displayprefs: st.displayprefs,
}
return st.storeProfiles()
}
func loadJSON(path string, obj interface{}) error { func loadJSON(path string, obj interface{}) error {
var file []byte var file []byte
var err error var err error

335
ts/accounts.ts Normal file
View File

@ -0,0 +1,335 @@
const checkCheckboxes = (): void => {
const defaultsButton = document.getElementById('accountsTabSetDefaults');
const deleteButton = document.getElementById('accountsTabDelete');
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let checked = checkboxes.length;
if (checked == 0) {
Unfocus(defaultsButton);
Unfocus(deleteButton);
} else {
Focus(defaultsButton);
Focus(deleteButton);
if (checked == 1) {
deleteButton.textContent = 'Delete User';
} else {
deleteButton.textContent = 'Delete Users';
}
}
}
const validateEmail = (email: string): boolean => /\S+@\S+\.\S+/.test(email);
function changeEmail(icon: HTMLElement, id: string): void {
const iconContent = icon.outerHTML;
icon.setAttribute('class', '');
const entry = icon.nextElementSibling as HTMLInputElement;
const ogEmail = entry.value;
entry.readOnly = false;
entry.classList.remove('form-control-plaintext');
entry.classList.add('form-control');
if (ogEmail == "") {
entry.placeholder = 'Address';
}
const tick = createEl(`
<i class="fa fa-check d-inline-block icon-button text-success" style="margin-left: 0.5rem; margin-right: 0.5rem;"></i>
`);
tick.onclick = (): void => {
const newEmail = entry.value;
if (!validateEmail(newEmail) || newEmail == ogEmail) {
return;
}
cross.remove();
const spinner = createEl(`
<div class="spinner-border spinner-border-sm" role="status" style="width: 1rem; height: 1rem; margin-left: 0.5rem;">
<span class="sr-only">Saving...</span>
</div>
`);
tick.replaceWith(spinner);
let send = {};
send[id] = newEmail;
_post("/modifyEmails", send, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
entry.nextElementSibling.remove();
} else {
entry.value = ogEmail;
}
}
});
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
};
const cross = createEl(`
<i class="fa fa-close d-inline-block icon-button text-danger"></i>
`);
cross.onclick = (): void => {
tick.remove();
cross.remove();
icon.outerHTML = iconContent;
entry.readOnly = true;
entry.classList.remove('form-control');
entry.classList.add('form-control-plaintext');
entry.placeholder = '';
entry.value = ogEmail;
};
icon.parentNode.appendChild(tick);
icon.parentNode.appendChild(cross);
};
var jfUsers: Array<Object>;
function populateUsers(): void {
const acList = document.getElementById('accountsList');
acList.innerHTML = `
<div class="d-flex align-items-center">
<strong>Getting Users...</strong>
<div class="spinner-border ml-auto" role="status" aria-hidden="true"></div>
</div>
`;
Unfocus(acList.parentNode.querySelector('thead'));
const accountsList = document.createElement('tbody');
accountsList.id = 'accountsList';
const generateEmail = (id: string, name: string, email: string): string => {
let entry: HTMLDivElement = document.createElement('div');
entry.id = 'email_' + id;
let emailValue: string = email;
if (emailValue == undefined) {
emailValue = "";
}
entry.innerHTML = `
<i class="fa fa-edit d-inline-block icon-button" style="margin-right: 2%;" onclick="changeEmail(this, '${id}')"></i>
<input type="email" class="form-control-plaintext form-control-sm text-muted d-inline-block addressText" id="address_${id}" style="width: auto;" value="${emailValue}" readonly>
`;
return entry.outerHTML;
};
const template = (id: string, username: string, email: string, lastActive: string, admin: boolean): string => {
let isAdmin = "No";
if (admin) {
isAdmin = "Yes";
}
let fci = "form-check-input";
if (bsVersion != 5) {
fci = "";
}
return `
<td nowrap="nowrap" class="align-middle" scope="row"><input class="${fci}" type="checkbox" value="" id="select_${id}" onclick="checkCheckboxes();"></td>
<td nowrap="nowrap" class="align-middle">${username}</td>
<td nowrap="nowrap" class="align-middle">${generateEmail(id, name, email)}</td>
<td nowrap="nowrap" class="align-middle">${lastActive}</td>
<td nowrap="nowrap" class="align-middle">${isAdmin}</td>
`;
};
_get("/getUsers", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
jfUsers = this.response['users'];
for (const user of jfUsers) {
let tr = document.createElement('tr');
tr.innerHTML = template(user['id'], user['name'], user['email'], user['last_active'], user['admin']);
accountsList.appendChild(tr);
}
Focus(acList.parentNode.querySelector('thead'));
acList.replaceWith(accountsList);
}
});
}
function populateRadios(): void {
const radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
let first = true;
for (const i in jfUsers) {
const user = jfUsers[i];
const radio = document.createElement('div');
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="defaultRadios" id="default_${user['id']}" ${checked}>
<label class="form-check-label" for="default_${user['id']}">${user['name']}</label>`;
radioList.appendChild(radio);
}
}
(<HTMLInputElement>document.getElementById('selectAll')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]');
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = (<HTMLInputElement>this).checked;
}
checkCheckboxes();
};
(<HTMLInputElement>document.getElementById('deleteModalNotify')).onclick = function (): void {
const textbox: HTMLElement = document.getElementById('deleteModalReasonBox');
if ((<HTMLInputElement>this).checked) {
Focus(textbox);
} else {
Unfocus(textbox);
}
};
(<HTMLButtonElement>document.getElementById('accountsTabDelete')).onclick = function (): void {
const deleteButton = this as HTMLButtonElement;
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let selected: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++) {
selected[i] = checkboxes[i].id.replace("select_", "");
}
let title = " user";
let msg = "Notify user";
if (selected.length > 1) {
title += "s";
msg += "s";
}
title = `Delete ${selected.length} ${title}`;
msg += " of account deletion";
document.getElementById('deleteModalTitle').textContent = title;
const dmNotify = document.getElementById('deleteModalNotify') as HTMLInputElement;
dmNotify.checked = false;
document.getElementById('deleteModalNotifyLabel').textContent = msg;
const dmReason = document.getElementById('deleteModalReason') as HTMLTextAreaElement;
dmReason.value = '';
Unfocus(document.getElementById('deleteModalReasonBox'));
const dmSend = document.getElementById('deleteModalSend') as HTMLButtonElement;
dmSend.textContent = 'Delete';
dmSend.onclick = function (): void {
const button = this as HTMLButtonElement;
const send = {
'users': selected,
'notify': dmNotify.checked,
'reason': dmReason.value
};
_post("/deleteUser", send, function (): void {
if (this.readyState == 4) {
if (this.status == 500) {
if ("error" in this.reponse) {
button.textContent = 'Failed';
} else {
button.textContent = 'Partial fail (check console)';
console.log(this.response);
}
setTimeout((): void => {
Unfocus(deleteButton);
deleteModal.hide();
}, 4000);
} else {
Unfocus(deleteButton);
deleteModal.hide()
}
populateUsers();
checkCheckboxes();
}
});
};
deleteModal.show();
};
(<HTMLInputElement>document.getElementById('selectAll')).checked = false;
(<HTMLButtonElement>document.getElementById('accountsTabSetDefaults')).onclick = function (): void {
const checkboxes: NodeListOf<HTMLInputElement> = document.getElementById('accountsList').querySelectorAll('input[type=checkbox]:checked');
let userIDs: Array<string> = new Array(checkboxes.length);
for (let i = 0; i < checkboxes.length; i++){
userIDs[i] = checkboxes[i].id.replace("select_", "");
}
if (userIDs.length == 0) {
return;
}
populateRadios();
let userString = 'user';
if (userIDs.length > 1) {
userString += "s";
}
document.getElementById('defaultsTitle').textContent = `Apply settings to ${userIDs.length} ${userString}`;
document.getElementById('userDefaultsDescription').textContent = `
Create an account and configure it to your liking, then choose it from below to apply to your selected users.
`;
document.getElementById('storeHomescreenLabel').textContent = `Apply homescreen layout`;
Focus(document.getElementById('defaultsSourceSection'));
(<HTMLSelectElement>document.getElementById('defaultsSource')).value = 'userTemplate';
Unfocus(document.getElementById('defaultUserRadios'));
document.getElementById('storeDefaults').onclick = (): void => storeDefaults(userIDs);
userDefaultsModal.show();
};
(<HTMLSelectElement>document.getElementById('defaultsSource')).addEventListener('change', function (): void {
const radios = document.getElementById('defaultUserRadios');
if (this.value == 'userTemplate') {
Unfocus(radios);
} else {
Focus(radios);
}
});
(<HTMLButtonElement>document.getElementById('newUserCreate')).onclick = function (): void {
const button = this as HTMLButtonElement;
const ogText = button.textContent;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating...
`;
const email: string = (<HTMLInputElement>document.getElementById('newUserEmail')).value;
var username: string = email;
if (document.getElementById('newUserName') != null) {
username = (<HTMLInputElement>document.getElementById('newUserName')).value;
}
const password: string = (<HTMLInputElement>document.getElementById('newUserPassword')).value;
if (!validateEmail(email) && email != "") {
return;
}
const send = {
'username': username,
'password': password,
'email': email
};
_post("/newUserAdmin", send, function (): void {
if (this.readyState == 4) {
rmAttr(button, 'btn-primary');
if (this.status == 200) {
addAttr(button, 'btn-success');
button.textContent = 'Success';
setTimeout((): void => {
rmAttr(button, 'btn-success');
addAttr(button, 'btn-primary');
button.textContent = ogText;
newUserModal.hide();
}, 1000);
populateUsers();
} else {
addAttr(button, 'btn-danger');
if ("error" in this.response) {
button.textContent = this.response["error"];
} else {
button.textContent = 'Failed';
}
setTimeout((): void => {
rmAttr(button, 'btn-danger');
addAttr(button, 'btn-primary');
button.textContent = ogText;
}, 2000);
populateUsers();
}
}
});
};
(<HTMLButtonElement>document.getElementById('accountsTabAddUser')).onclick = function (): void {
(<HTMLInputElement>document.getElementById('newUserEmail')).value = '';
(<HTMLInputElement>document.getElementById('newUserPassword')).value = '';
if (document.getElementById('newUserName') != null) {
(<HTMLInputElement>document.getElementById('newUserName')).value = '';
}
newUserModal.show();
};

271
ts/admin.ts Normal file
View File

@ -0,0 +1,271 @@
interface Window {
token: string;
}
// Set in admin.html
var cssFile: string;
const _post = (url: string, data: Object, onreadystatechange: () => void): void => {
let req = new XMLHttpRequest();
req.open("POST", url, true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.send(JSON.stringify(data));
};
const _get = (url: string, data: Object, onreadystatechange: () => void): void => {
let req = new XMLHttpRequest();
req.open("GET", url, true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = onreadystatechange;
req.send(JSON.stringify(data));
};
const rmAttr = (el: HTMLElement, attr: string): void => {
if (el.classList.contains(attr)) {
el.classList.remove(attr);
}
};
const addAttr = (el: HTMLElement, attr: string): void => el.classList.add(attr);
const Focus = (el: HTMLElement): void => rmAttr(el, 'unfocused');
const Unfocus = (el: HTMLElement): void => addAttr(el, 'unfocused');
interface TabSwitcher {
invitesEl: HTMLDivElement;
accountsEl: HTMLDivElement;
invitesTabButton: HTMLAnchorElement;
accountsTabButton: HTMLAnchorElement;
invites: () => void;
accounts: () => void;
}
const tabs: TabSwitcher = {
invitesEl: document.getElementById('invitesTab') as HTMLDivElement,
accountsEl: document.getElementById('accountsTab') as HTMLDivElement,
invitesTabButton: document.getElementById('invitesTabButton') as HTMLAnchorElement,
accountsTabButton: document.getElementById('accountsTabButton') as HTMLAnchorElement,
invites: (): void => {
Unfocus(tabs.accountsEl);
Focus(tabs.invitesEl);
rmAttr(tabs.accountsTabButton, "active");
addAttr(tabs.invitesTabButton, "active");
},
accounts: (): void => {
populateUsers();
(document.getElementById('selectAll') as HTMLInputElement).checked = false;
checkCheckboxes();
Unfocus(tabs.invitesEl);
Focus(tabs.accountsEl);
rmAttr(tabs.invitesTabButton, "active");
addAttr(tabs.accountsTabButton, "active");
}
};
tabs.invitesTabButton.onclick = tabs.invites;
tabs.accountsTabButton.onclick = tabs.accounts;
tabs.invites();
// Predefined colors for the theme button.
var buttonColor: string = "custom";
if (cssFile.includes("jf")) {
buttonColor = "rgb(255,255,255)";
} else if (cssFile == ("bs" + bsVersion + ".css")) {
buttonColor = "rgb(16,16,16)";
}
if (buttonColor != "custom") {
const switchButton = document.createElement('button') as HTMLButtonElement;
switchButton.classList.add('btn', 'btn-secondary');
switchButton.innerHTML = `
Theme
<i class="fa fa-circle circle" style="color: ${buttonColor}; margin-left: 0.4rem;" id="fakeButton"></i>
`;
switchButton.onclick = (): void => toggleCSS(document.getElementById('fakeButton'));
document.getElementById('headerButtons').appendChild(switchButton);
}
var loginModal = createModal('login');
var settingsModal = createModal('settingsMenu');
var userDefaultsModal = createModal('userDefaults');
var usersModal = createModal('users');
var restartModal = createModal('restartModal');
var refreshModal = createModal('refreshModal');
var aboutModal = createModal('aboutModal');
var deleteModal = createModal('deleteModal');
var newUserModal = createModal('newUserModal');
var availableProfiles: Array<string>;
window["token"] = "";
function toClipboard(str: string): void {
const el = document.createElement('textarea') as HTMLTextAreaElement;
el.value = str;
el.readOnly = true;
el.style.position = "absolute";
el.style.left = "-9999px";
document.body.appendChild(el);
const selected = document.getSelection().rangeCount > 0 ? document.getSelection().getRangeAt(0) : false;
el.select();
document.execCommand("copy");
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
}
function login(username: string, password: string, modal: boolean, button?: HTMLButtonElement, run?: (arg0: number) => void): void {
const req = new XMLHttpRequest();
req.responseType = 'json';
req.open("GET", "/getToken", true);
req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
req.onreadystatechange = function (): void {
if (this.readyState == 4) {
if (this.status != 200) {
let errorMsg = this.response["error"];
if (!errorMsg) {
errorMsg = "Unknown error";
}
if (modal) {
button.disabled = false;
button.textContent = errorMsg;
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.textContent = "Login";
}, 4000);
} else {
loginModal.show();
}
} else {
const data = this.response;
window.token = data["token"];
generateInvites();
setInterval((): void => generateInvites(), 60 * 1000);
addOptions(30, document.getElementById('days') as HTMLSelectElement);
addOptions(24, document.getElementById('hours') as HTMLSelectElement);
const minutes = document.getElementById('minutes') as HTMLSelectElement;
addOptions(59, minutes);
minutes.value = "30";
checkDuration();
if (modal) {
loginModal.hide();
}
Focus(document.getElementById('logoutButton'));
}
if (run) {
run(+this.status);
}
}
};
req.send();
}
function createEl(html: string): HTMLElement {
let div = document.createElement('div') as HTMLDivElement;
div.innerHTML = html;
return div.firstElementChild as HTMLElement;
}
(document.getElementById('loginForm') as HTMLFormElement).onsubmit = function (): boolean {
window.token = "";
const details = serializeForm('loginForm');
const button = document.getElementById('loginSubmit') as HTMLButtonElement;
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
login(details["username"], details["password"], true, button);
return false;
};
function storeDefaults(users: string | Array<string>): void {
// not sure if this does anything, but w/e
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
const button = document.getElementById('storeDefaults') as HTMLButtonElement;
const radio = document.querySelector('input[name=defaultRadios]:checked') as HTMLInputElement
let id = radio.id.replace("default_", "");
let route = "/setDefaults";
let data = {
"from": "user",
"id": id,
"homescreen": false
};
if ((document.getElementById('defaultsSource') as HTMLSelectElement).value == 'userTemplate') {
data["from"] = "template";
}
if (users != "all") {
data["apply_to"] = users;
route = "/applySettings";
}
if ((document.getElementById('storeDefaultHomescreen') as HTMLInputElement).checked) {
data["homescreen"] = true;
}
_post(route, data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-success");
button.disabled = false;
userDefaultsModal.hide();
}, 1000);
} else {
if ("error" in this.response) {
button.textContent = this.response["error"];
} else if (("policy" in this.response) || ("homescreen" in this.response)) {
button.textContent = "Failed (check console)";
} else {
button.textContent = "Failed";
}
addAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
}
generateInvites(true);
login("", "", false, null, (status: number): void => {
if (!(status == 200 || status == 204)) {
loginModal.show();
}
});
(document.getElementById('logoutButton') as HTMLButtonElement).onclick = function (): void {
_post("/logout", null, function (): boolean {
if (this.readyState == 4 && this.status == 200) {
window.token = "";
location.reload();
return false;
}
});
};

79
ts/animation.ts Normal file
View File

@ -0,0 +1,79 @@
// Used for animation on theme change
const whichTransitionEvent = (): string => {
const el = document.createElement('fakeElement');
const transitions = {
'transition': 'transitionend',
'OTransition': 'oTransitionEnd',
'MozTransition': 'transitionend',
'WebkitTransition': 'webkitTransitionEnd'
};
for (const t in transitions) {
if (el.style[t] !== undefined) {
return transitions[t];
}
}
return '';
};
var transitionEndEvent = whichTransitionEvent();
// Toggles between light and dark themes
const _toggleCSS = (): void => {
const els: NodeListOf<HTMLLinkElement> = document.querySelectorAll('link[rel="stylesheet"][type="text/css"]');
let cssEl = 0;
let remove = false;
if (els.length != 1) {
cssEl = 1;
remove = true
}
let href: string = "bs" + bsVersion;
if (!els[cssEl].href.includes(href + "-jf")) {
href += "-jf";
}
href += ".css";
let newEl = els[cssEl].cloneNode(true) as HTMLLinkElement;
newEl.href = href;
els[cssEl].parentNode.insertBefore(newEl, els[cssEl].nextSibling);
if (remove) {
els[0].remove();
}
document.cookie = "css=" + href;
}
// Toggles between light and dark themes, but runs animation if window small enough.
var buttonWidth = 0;
const toggleCSS = (el: HTMLElement): void => {
const switchToColor = window.getComputedStyle(document.body, null).backgroundColor;
// Max page width for animation to take place
let maxWidth = 1500;
if (window.innerWidth < maxWidth) {
// Calculate minimum radius to cover screen
const radius = Math.sqrt(Math.pow(window.innerWidth, 2) + Math.pow(window.innerHeight, 2));
const currentRadius = el.getBoundingClientRect().width / 2;
const scale = radius / currentRadius;
buttonWidth = +window.getComputedStyle(el, null).width;
document.body.classList.remove('smooth-transition');
el.style.transform = `scale(${scale})`;
el.style.color = switchToColor;
el.addEventListener(transitionEndEvent, function (): void {
if (this.style.transform.length != 0) {
_toggleCSS();
this.style.removeProperty('transform');
document.body.classList.add('smooth-transition');
}
}, false);
} else {
_toggleCSS();
el.style.color = switchToColor;
}
};
const rotateButton = (el: HTMLElement): void => {
if (el.classList.contains("rotated")) {
rmAttr(el, "rotated")
addAttr(el, "not-rotated");
} else {
rmAttr(el, "not-rotated");
addAttr(el, "rotated");
}
};

38
ts/bs4.ts Normal file
View File

@ -0,0 +1,38 @@
var bsVersion = 4;
const send_to_addess_enabled = document.getElementById('send_to_addess_enabled');
if (send_to_addess_enabled) {
send_to_addess_enabled.classList.remove("form-check-input");
}
const multiUseEnabled = document.getElementById('multiUseEnabled');
if (multiUseEnabled) {
multiUseEnabled.classList.remove("form-check-input");
}
function createModal(id: string, find?: boolean): any {
$(`#${id}`).on("shown.bs.modal", (): void => document.body.classList.add("modal-open"));
return {
show: function (): any {
const temp = ($(`#${id}`) as any).modal("show");
return temp;
},
hide: function (): any {
return ($(`#${id}`) as any).modal("hide");
}
};
}
function triggerTooltips(): void {
$("#settingsMenu").on("shown.bs.modal", (): void => {
const checkboxes = [].slice.call(document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return ($(el) as any).tooltip();
});
});
}

36
ts/bs5.ts Normal file
View File

@ -0,0 +1,36 @@
declare var bootstrap: any;
var bsVersion = 5;
function createModal(id: string, find?: boolean): any {
let modal: any;
if (find) {
modal = bootstrap.Modal.getInstance(document.getElementById(id));
} else {
modal = new bootstrap.Modal(document.getElementById(id));
}
document.getElementById(id).addEventListener('shown.bs.modal', (): void => document.body.classList.add("modal-open"));
return {
modal: modal,
show: function (): any {
const temp = this.modal.show();
return temp;
},
hide: function (): any { return this.modal.hide(); }
};
}
function triggerTooltips(): void {
(document.getElementById('settingsMenu') as HTMLButtonElement).addEventListener('shown.bs.modal', (): void => {
const checkboxes = [].slice.call(document.getElementById('settingsMenu').querySelectorAll('input[type="checkbox"]'));
for (const i in checkboxes) {
checkboxes[i].click();
checkboxes[i].click();
}
const tooltips = [].slice.call(document.querySelectorAll('a[data-toggle="tooltip"]'));
tooltips.map((el: HTMLAnchorElement): any => {
return new bootstrap.Tooltip(el);
});
});
}

378
ts/invites.ts Normal file
View File

@ -0,0 +1,378 @@
// Actually defined by templating in admin.html, this is just to avoid errors from tsc.
var notifications_enabled: any;
interface Invite {
code?: string;
expiresIn?: string;
empty: boolean;
remainingUses?: string;
email?: string;
usedBy?: Array<Array<string>>;
created?: string;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
}
const emptyInvite = (): Invite => { return { code: "None", empty: true } as Invite; }
function parseInvite(invite: Object): Invite {
let inv: Invite = { code: invite["code"], empty: false, };
if (invite["email"]) {
inv.email = invite["email"];
}
let time = ""
const f = ["days", "hours", "minutes"];
for (const i in f) {
if (invite[f[i]] != 0) {
time += `${invite[f[i]]}${f[i][0]} `;
}
}
inv.expiresIn = `Expires in ${time.slice(0, -1)}`;
if (invite["no-limit"]) {
inv.remainingUses = "∞";
} else if ("remaining-uses" in invite) {
inv.remainingUses = invite["remaining-uses"];
}
if ("used-by" in invite) {
inv.usedBy = invite["used-by"];
}
if ("created" in invite) {
inv.created = invite["created"];
}
if ("notify-expiry" in invite) {
inv.notifyExpiry = invite["notify-expiry"];
}
if ("notify-creation" in invite) {
inv.notifyCreation = invite["notify-creation"];
}
if ("profile" in invite) {
inv.profile = invite["profile"];
}
return inv;
}
function setNotify(el: HTMLElement): void {
let send = {};
let code: string;
let notifyType: string;
if (el.id.includes("Expiry")) {
code = el.id.replace("_notifyExpiry", "");
notifyType = "notify-expiry";
} else if (el.id.includes("Creation")) {
code = el.id.replace("_notifyCreation", "");
notifyType = "notify-creation";
}
send[code] = {};
send[code][notifyType] = (el as HTMLInputElement).checked;
_post("/setNotify", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
(el as HTMLInputElement).checked = !(el as HTMLInputElement).checked;
}
});
}
function genUsedBy(usedBy: Array<Array<string>>): string {
let uB = "";
if (usedBy && usedBy.length != 0) {
uB = `
<ul class="list-group list-group-flush">
<li class="list-group-item py-1">Users created:</li>
`;
for (const i in usedBy) {
uB += `
<li class="list-group-item py-1 disabled">
<div class="d-flex float-left">${usedBy[i][0]}</div>
<div class="d-flex float-right">${usedBy[i][1]}</div>
</li>
`;
}
uB += `</ul>`
}
return uB;
}
function addItem(invite: Invite): void {
const links = document.getElementById('invites');
const container = document.createElement('div') as HTMLDivElement;
container.id = invite.code;
const item = document.createElement('div') as HTMLDivElement;
item.classList.add('list-group-item', 'd-flex', 'justify-content-between', 'd-inline-block');
let link = "";
let innerHTML = `<a>None</a>`;
if (invite.empty) {
item.innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
${innerHTML}
</div>
`;
container.appendChild(item);
links.appendChild(container);
return;
}
link = window.location.href.split('#')[0] + "invite/" + invite.code;
innerHTML = `
<div class="d-flex align-items-center font-monospace" style="width: 40%;">
<a class="invite-link" href="${link}">${invite.code.replace(/-/g, '-')}</a>
<i class="fa fa-clipboard icon-button" onclick="toClipboard('${link}')" style="margin-right: 0.5rem; margin-left: 0.5rem;"></i>
`;
if (invite.email) {
let email = invite.email;
if (!invite.email.includes("Failed to send to")) {
email = `Sent to ${email}`;
}
innerHTML += `
<span class="text-muted" style="margin-left: 0.4rem; font-style: italic; font-size: 0.8rem;">${email}</span>
`;
}
innerHTML += `
</div>
<div style="text-align: right;">
<span id="${invite.code}_expiry" style="margin-right: 1rem;">${invite.expiresIn}</span>
<div style="display: inline-block;">
<button class="btn btn-outline-danger" onclick="deleteInvite('${invite.code}')">Delete</button>
<i class="fa fa-angle-down collapsed icon-button not-rotated" style="padding: 1rem; margin: -1rem -1rem -1rem 0;" data-toggle="collapse" aria-expanded="false" data-target="#${CSS.escape(invite.code)}_collapse" onclick="rotateButton(this)"></i>
</div>
</div>
`;
item.innerHTML = innerHTML;
container.appendChild(item);
let profiles = `
<label class="input-group-text" for="profile_${CSS.escape(invite.code)}">Profile: </label>
<select class="form-select" id="profile_${CSS.escape(invite.code)}" onchange="setProfile(this)">
<option value="NoProfile" selected>No Profile</option>
`;
for (const i in availableProfiles) {
let selected = "";
if (availableProfiles[i] == invite.profile) {
selected = "selected";
}
profiles += `<option value="${availableProfiles[i]}" ${selected}>${availableProfiles[i]}</option>`;
}
profiles += `</select>`;
let dateCreated: string;
if (invite.created) {
dateCreated = `<li class="list-group-item py-1">Created: ${invite.created}</li>`;
}
let middle: string;
if (notifications_enabled) {
middle = `
<div class="col" id="${CSS.escape(invite.code)}_notifyButtons">
<ul class="list-group list-group-flush">
Notify on:
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyExpiry" onclick="setNotify(this)" ${invite.notifyExpiry ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyExpiry">Expiry</label>
</li>
<li class="list-group-item py-1 form-check">
<input class="form-check-input" type="checkbox" value="" id="${CSS.escape(invite.code)}_notifyCreation" onclick="setNotify(this)" ${invite.notifyCreation ? "checked" : ""}>
<label class="form-check-label" for="${CSS.escape(invite.code)}_notifyCreation">User creation</label>
</li>
</ul>
</div>
`;
}
let right: string = genUsedBy(invite.usedBy)
const dropdown = document.createElement('div') as HTMLDivElement;
dropdown.id = `${CSS.escape(invite.code)}_collapse`;
dropdown.classList.add("collapse");
dropdown.innerHTML = `
<div class="container row align-items-start card-body">
<div class="col">
<ul class="list-group list-group-flush">
<li class="input-group py-1">
${profiles}
</li>
${dateCreated}
<li class="list-group-item py-1" id="${CSS.escape(invite.code)}_remainingUses">Remaining uses: ${invite.remainingUses}</li>
</ul>
</div>
${middle}
<div class="col" id="${CSS.escape(invite.code)}_usersCreated">
${right}
</div>
</div>
`;
container.appendChild(dropdown);
links.appendChild(container);
}
function updateInvite(invite: Invite): void {
document.getElementById(invite.code + "_expiry").textContent = invite.expiresIn;
const remainingUses: any = document.getElementById(CSS.escape(invite.code) + "_remainingUses");
if (remainingUses) {
remainingUses.textContent = `Remaining uses: ${invite.remainingUses}`;
}
document.getElementById(CSS.escape(invite.code) + "_usersCreated").innerHTML = genUsedBy(invite.usedBy);
}
// delete invite from DOM
const hideInvite = (code: string): void => document.getElementById(CSS.escape(code)).remove();
// delete invite from jfa-go
const deleteInvite = (code: string): void => _post("/deleteInvite", { "code": code }, function (): void {
if (this.readyState == 4) {
generateInvites();
}
});
function generateInvites(empty?: boolean): void {
if (empty) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
return;
}
_get("/getInvites", null, function (): void {
if (this.readyState == 4) {
let data = this.response;
availableProfiles = data['profiles'];
const Profiles = document.getElementById('inviteProfile') as HTMLSelectElement;
let innerHTML = "";
for (let i = 0; i < availableProfiles.length; i++) {
const profile = availableProfiles[i];
innerHTML += `
<option value="${profile}" ${(i == 0) ? "selected" : ""}>${profile}</option>
`;
}
innerHTML += `
<option value="NoProfile" ${(availableProfiles.length == 0) ? "selected" : ""}>No Profile</option>
`;
Profiles.innerHTML = innerHTML;
if (data['invites'] == null || data['invites'].length == 0) {
document.getElementById('invites').textContent = '';
addItem(emptyInvite());
return;
}
let items = document.getElementById('invites').children;
for (const i in data['invites']) {
let match = false;
const inv = parseInvite(data['invites'][i]);
for (const x in items) {
if (items[x].id == inv.code) {
match = true;
updateInvite(inv);
break;
}
}
if (!match) {
addItem(inv);
}
}
// second pass to check for expired invites
items = document.getElementById('invites').children;
for (let i = 0; i < items.length; i++) {
let exists = false;
for (const x in data['invites']) {
if (items[i].id == data['invites'][x]['code']) {
exists = true;
break;
}
}
if (!exists) {
hideInvite(items[i].id);
}
}
}
});
}
const addOptions = (length: number, el: HTMLSelectElement): void => {
for (let v = 0; v <= length; v++) {
const opt = document.createElement('option');
opt.textContent = ""+v;
opt.value = ""+v;
el.appendChild(opt);
}
el.value = "0";
};
function fixCheckboxes(): void {
const send_to_address: Array<HTMLInputElement> = [document.getElementById('send_to_address') as HTMLInputElement, document.getElementById('send_to_address_enabled') as HTMLInputElement];
if (send_to_address[0] != null) {
send_to_address[0].disabled = !send_to_address[1].checked;
}
const multiUseEnabled = document.getElementById('multiUseEnabled') as HTMLInputElement;
const multiUseCount = document.getElementById('multiUseCount') as HTMLInputElement;
const noUseLimit = document.getElementById('noUseLimit') as HTMLInputElement;
multiUseCount.disabled = !multiUseEnabled.checked;
noUseLimit.checked = false;
noUseLimit.disabled = !multiUseEnabled.checked;
}
fixCheckboxes();
(document.getElementById('inviteForm') as HTMLFormElement).onsubmit = function (): boolean {
const button = document.getElementById('generateSubmit') as HTMLButtonElement;
button.disabled = true;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
let send = serializeForm('inviteForm');
send["remaining-uses"] = +send["remaining-uses"];
if (!send['multiple-uses'] || send['no-limit']) {
delete send['remaining-uses'];
}
if (send["profile"] == "NoProfile") {
send["profile"] = "";
}
const sendToAddress: any = document.getElementById('send_to_address');
const sendToAddressEnabled: any = document.getElementById('send_to_address_enabled');
if (sendToAddress && sendToAddressEnabled) {
send['email'] = send['send_to_address'];
delete send['send_to_address'];
delete send['send_to_address_enabled'];
}
console.log(send);
_post("/generateInvite", send, function (): void {
if (this.readyState == 4) {
button.textContent = 'Generate';
button.disabled = false;
generateInvites();
}
});
return false;
};
triggerTooltips();
function setProfile(select: HTMLSelectElement): void {
if (!select.value) {
return;
}
let val = select.value;
if (select.value == "NoProfile") {
val = ""
}
const invite = select.id.replace("profile_", "");
const send = {
"invite": invite,
"profile": val
};
_post("/setProfile", send, function (): void {
if (this.readyState == 4 && this.status != 200) {
generateInvites();
}
});
}
function checkDuration(): void {
const boxVals: Array<number> = [+(document.getElementById("days") as HTMLSelectElement).value, +(document.getElementById("hours") as HTMLSelectElement).value, +(document.getElementById("minutes") as HTMLSelectElement).value];
const submit = document.getElementById('generateSubmit') as HTMLButtonElement;
if (boxVals.reduce((a: number, b: number): number => a + b) == 0) {
submit.disabled = true;
} else {
submit.disabled = false;
}
}
const nE: Array<string> = ["days", "hours", "minutes"];
for (const i in nE) {
document.getElementById(nE[i]).addEventListener("change", checkDuration);
}

83
ts/ombi.ts Normal file
View File

@ -0,0 +1,83 @@
const ombiDefaultsModal = createModal('ombiDefaults');
(document.getElementById('openOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this as HTMLButtonElement;
button.disabled = true;
const ogHTML = button.innerHTML;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
_get("/getOmbiUsers", null, function (): void {
if (this.readyState == 4) {
if (this.status == 200) {
const users = this.response['users'];
const radioList = document.getElementById('ombiUserRadios');
radioList.textContent = '';
let first = true;
for (const i in users) {
const user = users[i];
const radio = document.createElement('div') as HTMLDivElement;
radio.classList.add('form-check');
let checked = '';
if (first) {
checked = 'checked';
first = false;
}
radio.innerHTML = `
<input class="form-check-input" type="radio" name="ombiRadios" id="ombiDefault_${user['id']}" ${checked}>
<label class="form-check-label" for="ombiDefault_${user['id']}">${user['name']}</label>
`;
radioList.appendChild(radio);
}
button.disabled = false;
button.innerHTML = ogHTML;
const submitButton = document.getElementById('storeOmbiDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Submit';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-success");
rmAttr(submitButton, "btn-danger");
settingsModal.hide();
ombiDefaultsModal.show();
}
}
});
};
(document.getElementById('storeOmbiDefaults') as HTMLButtonElement).onclick = function (): void {
let button = this as HTMLButtonElement;
button.disabled = true;
const ogHTML = button.innerHTML;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
const radio = document.querySelector('input[name=ombiRadios]:checked') as HTMLInputElement;
const data = {
"id": radio.id.replace("ombiDefault_", "")
};
_post("/setOmbiDefaults", data, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
button.textContent = "Success";
addAttr(button, "btn-success");
rmAttr(button, "btn-danger");
rmAttr(button, "btn-primary");
button.disabled = false;
setTimeout((): void => ombiDefaultsModal.hide(), 1000);
} else {
button.textContent = "Failed";
rmAttr(button, "btn-primary");
addAttr(button, "btn-danger");
setTimeout((): void => {
button.textContent = "Submit";
addAttr(button, "btn-primary");
rmAttr(button, "btn-danger");
button.disabled = false;
}, 1000);
}
}
});
};

35
ts/serialize.ts Normal file
View File

@ -0,0 +1,35 @@
function serializeForm(id: string): Object {
const form = document.getElementById(id) as HTMLFormElement;
let formData = {};
for (let i = 0; i < form.elements.length; i++) {
const el = form.elements[i];
if ((el as HTMLInputElement).type == "submit") {
continue;
}
let name = (el as HTMLInputElement).name;
if (!name) {
name = el.id;
}
switch ((el as HTMLInputElement).type) {
case "checkbox":
formData[name] = (el as HTMLInputElement).checked;
break;
case "text":
case "password":
case "email":
case "number":
formData[name] = (el as HTMLInputElement).value;
break;
case "select-one":
case "select":
let val: string = (el as HTMLSelectElement).value.toString();
if (!isNaN(val as any)) {
formData[name] = +val;
} else {
formData[name] = val;
}
break;
}
}
return formData;
}

206
ts/settings.ts Normal file
View File

@ -0,0 +1,206 @@
var config: Object = {};
var modifiedConfig: Object = {};
function sendConfig(modalID: string, restart?: boolean): void {
modifiedConfig["restart-program"] = restart;
_post("/modifyConfig", modifiedConfig, function (): void {
if (this.readyState == 4) {
if (this.status == 200 || this.status == 204) {
createModal(modalID, true).hide();
if (modalID != "settingsMenu") {
settingsModal.hide();
}
}
if (restart) {
refreshModal.show();
}
}
});
}
(document.getElementById('openDefaultsWizard') as HTMLButtonElement).onclick = function (): void {
const button = this as HTMLButtonElement;
button.disabled = true;
const ogHTML = button.innerHTML;
button.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>
Loading...`;
_get("/getUsers", null, function (): void {
if (this.readyState == 4) {
if (this.status == 200) {
jfUsers = this.response['users'];
populateRadios();
button.disabled = false;
button.innerHTML = ogHTML;
const submitButton = document.getElementById('storeDefaults') as HTMLButtonElement;
submitButton.disabled = false;
submitButton.textContent = 'Submit';
addAttr(submitButton, "btn-primary");
rmAttr(submitButton, "btn-danger");
rmAttr(submitButton, "btn-success");
document.getElementById('defaultsTitle').textContent = `New user defaults`;
document.getElementById('userDefaultsDescription').textContent = `
Create an account and configure it to your liking, then choose it from below to store the settings as a template for all new users.`;
document.getElementById('storeHomescreenLabel').textContent = `Store homescreen layout`;
(document.getElementById('defaultsSource') as HTMLSelectElement).value = 'fromUser';
document.getElementById('defaultsSourceSection').classList.add('unfocused');
(document.getElementById('storeDefaults') as HTMLButtonElement).onclick = (): void => storeDefaults('all');
Focus(document.getElementById('defaultUserRadios'));
settingsModal.hide();
userDefaultsModal.show();
}
}
});
};
(document.getElementById('openAbout') as HTMLButtonElement).onclick = (): void => {
settingsModal.hide();
aboutModal.show();
};
(document.getElementById('openSettings') as HTMLButtonElement).onclick = (): void => _get("/getConfig", null, function (): void {
if (this.readyState == 4 && this.status == 200) {
const settingsList = document.getElementById('settingsList');
settingsList.textContent = '';
config = this.response;
for (const i in config["order"]) {
const section: string = config["order"][i]
const sectionCollapse = document.createElement('div') as HTMLDivElement;
addAttr(sectionCollapse, "collapse");
sectionCollapse.id = section;
const title: string = config[section]["meta"]["name"];
const description: string = config[section]["meta"]["description"];
const entryListID: string = `${section}_entryList`;
// const footerID: string = `${section}_footer`;
sectionCollapse.innerHTML = `
<div class="card card-body">
<small class="text-muted">${description}</small>
<div class="${entryListID}">
</div>
</div>
`;
for (const x in config[section]["order"]) {
const entry: string = config[section]["order"][x];
if (entry == "meta") {
continue;
}
let entryName: string = config[section][entry]["name"];
let required = false;
if (config[section][entry]["required"]) {
entryName += ` <sup class="text-danger">*</sup>`;
required = true;
}
if (config[section][entry]["requires_restart"]) {
entryName += ` <sup class="text-danger">R</sup>`;
}
if ("description" in config[section][entry]) {
entryName +=`
<a class="text-muted" href="#" data-toggle="tooltip" data-placement="right" title="${config[section][entry]['description']}"><i class="fa fa-question-circle-o"></i></a>
`;
}
const entryValue: boolean | string = config[section][entry]["value"];
const entryType: string = config[section][entry]["type"];
const entryGroup = document.createElement('div');
if (entryType == "bool") {
entryGroup.classList.add("form-check");
entryGroup.innerHTML = `
<input class="form-check-input" type="checkbox" value="" id="${section}_${entry}" ${(entryValue as boolean) ? 'checked': ''} ${required ? 'required' : ''}>
<label class="form-check-label" for="${section}_${entry}">${entryName}</label>
`;
(entryGroup.querySelector('input[type=checkbox]') as HTMLInputElement).onclick = function (): void {
const me = this as HTMLInputElement;
for (const y in config["order"]) {
const sect: string = config["order"][y];
for (const z in config[sect]["order"]) {
const ent: string = config[sect]["order"][z];
if (`${sect}_${config[sect][ent]['depends_true']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = !(me.checked);
} else if (`${sect}_${config[sect][ent]['depends_false']}` == me.id) {
(document.getElementById(`${sect}_${ent}`) as HTMLInputElement).disabled = me.checked;
}
}
}
};
} else if ((entryType == 'text') || (entryType == 'email') || (entryType == 'password') || (entryType == 'number')) {
entryGroup.classList.add("form-group");
entryGroup.innerHTML = `
<label for="${section}_${entry}">${entryName}</label>
<input type="${entryType}" class="form-control" id="${section}_${entry}" aria-describedby="${entry}" value="${entryValue}" ${required ? 'required' : ''}>
`;
} else if (entryType == 'select') {
entryGroup.classList.add("form-group");
const entryOptions: Array<string> = config[section][entry]["options"];
let innerGroup = `
<label for="${section}_${entry}">${entryName}</label>
<select class="form-control" id="${section}_${entry}" ${required ? 'required' : ''}>
`;
for (const z in entryOptions) {
const entryOption = entryOptions[z];
let selected: boolean = (entryOption == entryValue);
innerGroup += `
<option value="${entryOption}" ${selected ? 'selected' : ''}>${entryOption}</option>
`;
}
innerGroup += `</select>`;
entryGroup.innerHTML = innerGroup;
}
sectionCollapse.getElementsByClassName(entryListID)[0].appendChild(entryGroup);
}
settingsList.innerHTML += `
<button type="button" class="list-group-item list-group-item-action" id="${section}_button" data-toggle="collapse" data-target="#${section}">${title}</button>
`;
settingsList.appendChild(sectionCollapse);
}
settingsModal.show();
}
});
(document.getElementById('settingsSave') as HTMLButtonElement).onclick = function (): void {
modifiedConfig = {};
let restartSettingsChanged = false;
let settingsChanged = false;
for (const i in config["order"]) {
const section = config["order"][i];
for (const x in config[section]["order"]) {
const entry = config[section]["order"][x];
if (entry == "meta") {
continue;
}
let val: string;
const entryID = `${section}_${entry}`;
const el = document.getElementById(entryID) as HTMLInputElement;
if (el.type == "checkbox") {
val = el.checked.toString();
} else {
val = el.value.toString();
}
if (val != config[section][entry]["value"]) {
if (!(section in modifiedConfig)) {
modifiedConfig[section] = {};
}
modifiedConfig[section][entry] = val;
settingsChanged = true;
if (config[section][entry]["requires_restart"]) {
restartSettingsChanged = true;
}
}
}
}
if (restartSettingsChanged) {
(document.getElementById('applyRestarts') as HTMLButtonElement).onclick = (): void => sendConfig("restartModal");
const restartButton = document.getElementById('applyAndRestart') as HTMLButtonElement;
if (restartButton) {
restartButton.onclick = (): void => sendConfig("restartModal", true);
}
settingsModal.hide();
restartModal.show();
} else if (settingsChanged) {
sendConfig("settingsMenu");
} else {
settingsModal.hide();
}
};

8
ts/tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"outDir": "../data/static",
"target": "es6",
"lib": ["dom", "es2017"],
"types": ["jquery"]
}
}