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

Compare commits

..

8 Commits

Author SHA1 Message Date
35a0be6a2c
Remove view settings button
i doubt there's much use for it.
2020-09-18 22:06:28 +01:00
6b81358cd2
improve tab appearance 2020-09-18 21:57:36 +01:00
d4b94bc9d9
add user button
added create user button for the admin to use.
2020-09-18 00:59:59 +01:00
9213f2a078
Add account deletion with email notification
Select users to delete, then optionally opt to notify the user in an
email with a provided reason.
2020-09-17 23:50:07 +01:00
2b84e45b65
fix table wrapping 2020-09-17 21:34:25 +01:00
7ac750879f
Remove email addresses in settings, move to accounts tab
still a little rough, but it works.
2020-09-17 21:23:45 +01:00
cd61989495
Initial features of accounts tab
It's rough right now, but the accounts tab shows a list of users and
info. Right now the only action available is to apply settings (from
template or another user) to a selection of users. More to come.
2020-09-17 16:51:19 +01:00
a8b4842895
add more info to getUsers, move admin.html css to scss files
more deatils included in getUsers response in prep for feature request #5. bs4 and bs5 css are now compiled instead of being copied.
2020-09-16 21:42:22 +01:00
24 changed files with 1221 additions and 368 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ mail/*.html
scss/*.css* scss/*.css*
scss/bs4/*.css* scss/bs4/*.css*
scss/bs5/*.css* scss/bs5/*.css*
data/static/*.css
data/config-base.json data/config-base.json
data/config-default.ini data/config-default.ini
data/*.html data/*.html

216
api.go
View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -179,6 +180,58 @@ type newUserReq struct {
Code string `json:"code"` Code string `json:"code"`
} }
func (app *appContext) NewUserAdmin(gc *gin.Context) {
var req newUserReq
gc.BindJSON(&req)
existingUser, _, _ := app.jf.userByName(req.Username, false)
if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username)
app.info.Printf("%s New user failed: %s", req.Username, msg)
respond(401, msg, gc)
return
}
user, status, err := app.jf.newUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Username, status)
respond(401, "Unknown error", gc)
return
}
var id string
if user["Id"] != nil {
id = user["Id"].(string)
}
if len(app.storage.policy) != 0 {
status, err = app.jf.setPolicy(id, app.storage.policy)
if !(status == 200 || status == 204) {
app.err.Printf("%s: Failed to set user policy: Code %d", req.Username, status)
}
}
if len(app.storage.configuration) != 0 && len(app.storage.displayprefs) != 0 {
status, err = app.jf.setConfiguration(id, app.storage.configuration)
if (status == 200 || status == 204) && err == nil {
status, err = app.jf.setDisplayPreferences(id, app.storage.displayprefs)
} else {
app.err.Printf("%s: Failed to set configuration template: Code %d", req.Username, status)
}
}
if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email
app.storage.storeEmails()
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
app.storage.loadOmbiTemplate()
if len(app.storage.ombi_template) != 0 {
errors, code, err := app.ombi.newUser(req.Username, req.Password, req.Email, app.storage.ombi_template)
if err != nil || code != 200 {
app.info.Printf("Failed to create Ombi user (%d): %s", code, err)
app.debug.Printf("Errors reported by Ombi: %s", strings.Join(errors, ", "))
} else {
app.info.Println("Created Ombi user")
}
}
}
}
func (app *appContext) NewUser(gc *gin.Context) { func (app *appContext) NewUser(gc *gin.Context) {
var req newUserReq var req newUserReq
gc.BindJSON(&req) gc.BindJSON(&req)
@ -273,6 +326,51 @@ func (app *appContext) NewUser(gc *gin.Context) {
gc.JSON(200, validation) gc.JSON(200, validation)
} }
type deleteUserReq struct {
Users []string `json:"users"`
Notify bool `json:"notify"`
Reason string `json:"reason"`
}
func (app *appContext) DeleteUser(gc *gin.Context) {
var req deleteUserReq
gc.BindJSON(&req)
errors := map[string]string{}
for _, userID := range req.Users {
status, err := app.jf.deleteUser(userID)
if !(status == 200 || status == 204) || err != nil {
errors[userID] = fmt.Sprintf("%d: %s", status, err)
}
if req.Notify {
addr, ok := app.storage.emails[userID]
if addr != nil && ok {
go func(userID, reason, address string) {
msg, err := app.email.constructDeleted(reason, app)
if err != nil {
app.err.Printf("%s: Failed to construct account deletion email", userID)
app.debug.Printf("%s: Error: %s", userID, err)
} else if err := app.email.send(address, msg); err != nil {
app.err.Printf("%s: Failed to send to %s", userID, address)
app.debug.Printf("%s: Error: %s", userID, err)
} else {
app.info.Printf("%s: Sent invite email to %s", userID, address)
}
}(userID, req.Reason, addr.(string))
}
}
}
app.jf.cacheExpiry = time.Now()
if len(errors) == len(req.Users) {
respond(500, "Failed", gc)
app.err.Printf("Account deletion failed: %s", errors[req.Users[0]])
return
} else if len(errors) != 0 {
gc.JSON(500, errors)
return
}
gc.JSON(200, map[string]bool{"success": true})
}
type generateInviteReq struct { type generateInviteReq struct {
Days int `json:"days"` Days int `json:"days"`
Hours int `json:"hours"` Hours int `json:"hours"`
@ -458,13 +556,29 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
respond(401, "Code doesn't exist", gc) respond(401, "Code doesn't exist", gc)
} }
type userResp struct { type dateToParse struct {
UserList []respUser `json:"users"` Parsed time.Time `json:"parseme"`
}
// json magically parses datetimes so why not
func parseDt(date string) time.Time {
timeJSON := []byte("{ \"parseme\": \"" + date + "\" }")
var parsed dateToParse
json.Unmarshal(timeJSON, &parsed)
return parsed.Parsed
} }
type respUser struct { type respUser struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
// this magically parses a string to time.time
LastActive string `json:"last_active"`
Admin bool `json:"admin"`
}
type userResp struct {
UserList []respUser `json:"users"`
} }
func (app *appContext) GetUsers(gc *gin.Context) { func (app *appContext) GetUsers(gc *gin.Context) {
@ -480,10 +594,18 @@ func (app *appContext) GetUsers(gc *gin.Context) {
} }
for _, jfUser := range users { for _, jfUser := range users {
var user respUser var user respUser
user.LastActive = "n/a"
if jfUser["LastActivityDate"] != nil {
date := parseDt(jfUser["LastActivityDate"].(string))
user.LastActive = app.formatDatetime(date)
}
user.ID = jfUser["Id"].(string)
user.Name = jfUser["Name"].(string) user.Name = jfUser["Name"].(string)
user.Admin = jfUser["Policy"].(map[string]interface{})["IsAdministrator"].(bool)
if email, ok := app.storage.emails[jfUser["Id"].(string)]; ok { if email, ok := app.storage.emails[jfUser["Id"].(string)]; ok {
user.Email = email.(string) user.Email = email.(string)
} }
resp.UserList = append(resp.UserList, user) resp.UserList = append(resp.UserList, user)
} }
gc.JSON(200, resp) gc.JSON(200, resp)
@ -516,6 +638,7 @@ func (app *appContext) OmbiUsers(gc *gin.Context) {
func (app *appContext) ModifyEmails(gc *gin.Context) { func (app *appContext) ModifyEmails(gc *gin.Context) {
var req map[string]string var req map[string]string
gc.BindJSON(&req) gc.BindJSON(&req)
fmt.Println(req)
app.debug.Println("Email modification requested") app.debug.Println("Email modification requested")
users, status, err := app.jf.getUsers(false) users, status, err := app.jf.getUsers(false)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
@ -525,7 +648,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
return return
} }
for _, jfUser := range users { for _, jfUser := range users {
if address, ok := req[jfUser["Name"].(string)]; ok { if address, ok := req[jfUser["Id"].(string)]; ok {
app.storage.emails[jfUser["Id"].(string)] = address app.storage.emails[jfUser["Id"].(string)] = address
} }
} }
@ -550,22 +673,24 @@ func (app *appContext) SetOmbiDefaults(gc *gin.Context) {
} }
type defaultsReq struct { type defaultsReq struct {
Username string `json:"username"` From string `json:"from"`
Homescreen bool `json:"homescreen"` ApplyTo []string `json:"apply_to"`
ID string `json:"id"`
Homescreen bool `json:"homescreen"`
} }
func (app *appContext) SetDefaults(gc *gin.Context) { func (app *appContext) SetDefaults(gc *gin.Context) {
var req defaultsReq var req defaultsReq
gc.BindJSON(&req) gc.BindJSON(&req)
app.info.Printf("Getting user defaults from \"%s\"", req.Username) userID := req.ID
user, status, err := app.jf.userByName(req.Username, false) user, status, err := app.jf.userById(userID, false)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin: Code %d", status) app.err.Printf("Failed to get user from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err) app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get user", gc) respond(500, "Couldn't get user", gc)
return return
} }
userID := user["Id"].(string) app.info.Printf("Getting user defaults from \"%s\"", user["Name"].(string))
policy := user["Policy"].(map[string]interface{}) policy := user["Policy"].(map[string]interface{})
app.storage.policy = policy app.storage.policy = policy
app.storage.storePolicy() app.storage.storePolicy()
@ -590,6 +715,81 @@ func (app *appContext) SetDefaults(gc *gin.Context) {
gc.JSON(200, map[string]bool{"success": true}) gc.JSON(200, map[string]bool{"success": true})
} }
func (app *appContext) ApplySettings(gc *gin.Context) {
var req defaultsReq
gc.BindJSON(&req)
applyingFrom := "template"
var policy, configuration, displayprefs map[string]interface{}
if req.From == "template" {
if len(app.storage.policy) == 0 {
respond(500, "No policy template available", gc)
return
}
app.storage.loadPolicy()
policy = app.storage.policy
if req.Homescreen {
if len(app.storage.configuration) == 0 || len(app.storage.displayprefs) == 0 {
respond(500, "No homescreen template available", gc)
return
}
configuration = app.storage.configuration
displayprefs = app.storage.displayprefs
}
} else if req.From == "user" {
applyingFrom = "user"
user, status, err := app.jf.userById(req.ID, false)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get user", gc)
return
}
applyingFrom = "\"" + user["Name"].(string) + "\""
policy = user["Policy"].(map[string]interface{})
if req.Homescreen {
displayprefs, status, err = app.jf.getDisplayPreferences(req.ID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get DisplayPrefs: Code %d", status)
app.debug.Printf("Error: %s", err)
respond(500, "Couldn't get displayprefs", gc)
return
}
configuration = user["Configuration"].(map[string]interface{})
}
}
app.info.Printf("Applying settings to %d user(s) from %s", len(req.ApplyTo), applyingFrom)
errors := map[string]map[string]string{
"policy": map[string]string{},
"homescreen": map[string]string{},
}
for _, id := range req.ApplyTo {
status, err := app.jf.setPolicy(id, policy)
if !(status == 200 || status == 204) || err != nil {
errors["policy"][id] = fmt.Sprintf("%d: %s", status, err)
}
if req.Homescreen {
status, err = app.jf.setConfiguration(id, configuration)
errorString := ""
if !(status == 200 || status == 204) || err != nil {
errorString += fmt.Sprintf("Configuration %d: %s ", status, err)
} else {
status, err = app.jf.setDisplayPreferences(id, displayprefs)
if !(status == 200 || status == 204) || err != nil {
errorString += fmt.Sprintf("Displayprefs %d: %s ", status, err)
}
}
if errorString != "" {
errors["homescreen"][id] = errorString
}
}
}
code := 200
if len(errors["policy"]) == len(req.ApplyTo) || len(errors["homescreen"]) == len(req.ApplyTo) {
code = 500
}
gc.JSON(code, errors)
}
func (app *appContext) GetConfig(gc *gin.Context) { func (app *appContext) GetConfig(gc *gin.Context) {
app.info.Println("Config requested") app.info.Println("Config requested")
resp := map[string]interface{}{} resp := map[string]interface{}{}

View File

@ -69,6 +69,9 @@ func (app *appContext) loadConfig() error {
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html"))) app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString(filepath.Join(app.local_path, "created.html")))
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt"))) app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString(filepath.Join(app.local_path, "created.txt")))
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.local_path, "deleted.html")))
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.local_path, "deleted.txt")))
app.email = NewEmailer(app) app.email = NewEmailer(app)
return nil return nil

View File

@ -543,6 +543,36 @@
"description": "API Key. Get this from the first tab in Ombi settings." "description": "API Key. Get this from the first tab in Ombi settings."
} }
}, },
"deletion": {
"meta": {
"name": "Account Deletion",
"description": "Subject/email files for account deletion emails."
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Your account was deleted - Jellyfin",
"description": "Subject of account deletion emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
},
"files": { "files": {
"meta": { "meta": {
"name": "File Storage", "name": "File Storage",

View File

@ -10,6 +10,7 @@
"mailgun", "mailgun",
"smtp", "smtp",
"ombi", "ombi",
"deletion",
"files" "files"
], ],
"jellyfin": { "jellyfin": {
@ -634,6 +635,41 @@
"description": "API Key. Get this from the first tab in Ombi settings." "description": "API Key. Get this from the first tab in Ombi settings."
} }
}, },
"deletion": {
"order": [
"subject",
"email_html",
"email_text"
],
"meta": {
"name": "Account Deletion",
"description": "Subject/email files for account deletion emails."
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"type": "text",
"value": "Your account was deleted - Jellyfin",
"description": "Subject of account deletion emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
},
"files": { "files": {
"order": [ "order": [
"invites", "invites",

363
data/static/accounts.js Normal file
View File

@ -0,0 +1,363 @@
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";
if (selected.length > 1) {
title += "s";
}
title = "Delete " + selected.length + title;
document.getElementById('deleteModalTitle').textContent = title;
document.getElementById('deleteModalNotify').checked = false;
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";
}
return `
<td nowrap="nowrap" class="align-middle" scope="row"><input class="form-check-input" 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;
}
let radioList = document.getElementById('defaultUserRadios');
radioList.textContent = '';
let first = true;
for (user of jfUsers) {
let radio = document.createElement('div');
radio.classList.add('radio');
let checked = 'checked';
if (first) {
first = false;
} else {
checked = '';
}
radio.innerHTML = `
<label><input type="radio" name="defaultRadios" id="default_${user['id']}" style="margin-right: 1rem;" ${checked}>${user['name']}</label>`;
radioList.appendChild(radio);
}
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();
};

View File

@ -1,3 +1,36 @@
const tabs = {
invitesEl: document.getElementById('invitesTab'),
accountsEl: document.getElementById('accountsTab'),
invitesTabButton: document.getElementById('invitesTabButton'),
accountsTabButton: document.getElementById('accountsTabButton'),
invites: function() {
if (tabs.invitesEl.classList.contains('unfocused')) {
tabs.accountsEl.classList.add('unfocused');
tabs.invitesEl.classList.remove('unfocused');
}
if (tabs.accountsTabButton.classList.contains("active")) {
tabs.accountsTabButton.classList.remove("active");
}
tabs.invitesTabButton.classList.add("active");
},
accounts: function() {
populateUsers();
if (tabs.accountsEl.classList.contains('unfocused')) {
tabs.invitesEl.classList.add('unfocused');
tabs.accountsEl.classList.remove('unfocused');
}
if (tabs.invitesTabButton.classList.contains("active")) {
tabs.invitesTabButton.classList.remove("active");
tabs.accountsTabButton.classList.add("active");
}
}
};
tabs.invitesTabButton.onclick = tabs.invites;
tabs.accountsTabButton.onclick = tabs.accounts;
tabs.invites();
// Used for theme change animation // Used for theme change animation
function whichTransitionEvent() { function whichTransitionEvent() {
let t; let t;
@ -101,6 +134,8 @@ var usersModal = createModal('users');
var restartModal = createModal('restartModal'); var restartModal = createModal('restartModal');
var refreshModal = createModal('refreshModal'); var refreshModal = createModal('refreshModal');
var aboutModal = createModal('aboutModal'); var aboutModal = createModal('aboutModal');
var deleteModal = createModal('deleteModal');
var newUserModal = createModal('newUserModal');
// Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>, <notify on expiry>, <notify on creation>] // Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>, <notify on expiry>, <notify on creation>]
function parseInvite(invite, empty = false) { function parseInvite(invite, empty = false) {
@ -638,7 +673,7 @@ document.getElementById('openDefaultsWizard').onclick = function() {
checked = ''; checked = '';
} }
radio.innerHTML = radio.innerHTML =
`<label><input type="radio" name="defaultRadios" id="default_${user['name']}" style="margin-right: 1rem;" ${checked}>${user['name']}</label>`; `<label><input type="radio" name="defaultRadios" id="default_${user['id']}" style="margin-right: 1rem;" ${checked}>${user['name']}</label>`;
radioList.appendChild(radio); radioList.appendChild(radio);
} }
let button = document.getElementById('openDefaultsWizard'); let button = document.getElementById('openDefaultsWizard');
@ -655,6 +690,19 @@ document.getElementById('openDefaultsWizard').onclick = function() {
submitButton.classList.add('btn-primary'); submitButton.classList.add('btn-primary');
} }
settingsModal.hide(); settingsModal.hide();
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').value = 'fromUser';
document.getElementById('defaultsSourceSection').classList.add('unfocused');
document.getElementById('storeDefaults').onclick = function() {
storeDefaults('all');
};
const list = document.getElementById('defaultUserRadios');
if (list.classList.contains('unfocused')) {
list.classList.remove('unfocused');
}
userDefaultsModal.show(); userDefaultsModal.show();
} }
} }
@ -662,54 +710,81 @@ document.getElementById('openDefaultsWizard').onclick = function() {
req.send(); req.send();
}; };
document.getElementById('storeDefaults').onclick = function () { function storeDefaults(users) {
this.disabled = true; this.disabled = true;
this.innerHTML = this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' + '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...'; 'Loading...';
let button = document.getElementById('storeDefaults'); let button = document.getElementById('storeDefaults');
let radios = document.getElementsByName('defaultRadios'); let radios = document.getElementsByName('defaultRadios');
let id = '';
for (let radio of radios) { for (let radio of radios) {
if (radio.checked) { if (radio.checked) {
let data = { id = radio.id.replace('default_', '');
'username': radio.id.slice(8), break;
'homescreen': false};
if (document.getElementById('storeDefaultHomescreen').checked) {
data['homescreen'] = true;
}
let req = new XMLHttpRequest();
req.open("POST", "/setDefaults", 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) {
button.textContent = "Success";
if (button.classList.contains('btn-danger')) {
button.classList.remove('btn-danger');
} else if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
}
button.classList.add('btn-success');
button.disabled = false;
setTimeout(function() { userDefaultsModal.hide(); }, 1000);
} else {
button.textContent = "Failed";
button.classList.remove('btn-primary');
button.classList.add('btn-danger');
setTimeout(function() {
let button = document.getElementById('storeDefaults');
button.textContent = "Submit";
button.classList.remove('btn-danger');
button.classList.add('btn-primary');
button.disabled = false;
}, 1000);
}
}
};
req.send(JSON.stringify(data));
} }
} }
let route = '/setDefaults';
let data = {
'from': 'user',
'id': id,
'homescreen': false
};
if (document.getElementById('defaultsSource').value == 'userTemplate') {
data['from'] = 'template';
}
if (users != 'all') {
data['apply_to'] = users;
route = '/applySettings';
}
if (document.getElementById('storeDefaultHomescreen').checked) {
data['homescreen'] = true;
}
let req = new XMLHttpRequest();
req.open("POST", route, 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 == 200 || this.status == 204) {
button.textContent = "Success";
if (button.classList.contains('btn-danger')) {
button.classList.remove('btn-danger');
} else if (button.classList.contains('btn-primary')) {
button.classList.remove('btn-primary');
}
button.classList.add('btn-success');
button.disabled = false;
setTimeout(function() {
let button = document.getElementById('storeDefaults');
button.textContent = "Submit";
button.classList.remove('btn-success');
button.classList.add('btn-primary');
button.disabled = false;
userDefaultsModal.hide();
}, 1000);
} else {
if ("error" in req.response) {
button.textContent = req.response["error"];
} else if (("policy" in req.response) || ("homescreen" in req.response)) {
button.textContent = "Failed (Check JS Console)";
} else {
button.textContent = "Failed";
}
button.classList.remove('btn-primary');
button.classList.add('btn-danger');
setTimeout(function() {
let button = document.getElementById('storeDefaults');
button.textContent = "Submit";
button.classList.remove('btn-danger');
button.classList.add('btn-primary');
button.disabled = false;
}, 1000);
}
}
};
req.send(JSON.stringify(data));
}; };
var ombiDefaultsModal = ''; var ombiDefaultsModal = '';
@ -813,104 +888,6 @@ if (ombiEnabled) {
}; };
} }
document.getElementById('openUsers').onclick = function () {
this.disabled = true;
this.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
let req = new XMLHttpRequest();
req.open("GET", "/getUsers", true);
req.responseType = 'json';
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.onreadystatechange = function() {
if (this.readyState == 4) {
if (this.status == 200) {
let list = document.getElementById('userList');
list.textContent = '';
if (document.getElementById('saveUsers')) {
document.getElementById('saveUsers').remove();
}
let users = req.response['users'];
for (let user of users) {
let entry = document.createElement('div');
entry.classList.add('form-group', 'list-group-item', 'py-1');
entry.id = 'user_' + user['name'];
let label = document.createElement('label');
label.classList.add('d-inline-block');
label.setAttribute('for', 'address_' + user['email']);
label.textContent = user['name'];
entry.appendChild(label);
let address = document.createElement('input');
address.setAttribute('type', 'email');
address.readOnly = true;
address.classList.add('form-control-plaintext', 'text-muted', 'd-inline-block', 'addressText');
address.id = 'address_' + user['name'];
address.setAttribute('style', 'width: auto; margin-left: 2%;');
if (typeof(user['email']) != 'undefined') {
address.value = user['email'];
}
let editButton = document.createElement('i');
editButton.classList.add('fa', 'fa-edit', 'd-inline-block', 'icon-button');
editButton.setAttribute('style', 'margin-left: 2%;');
editButton.onclick = function() {
this.classList.remove('fa', 'fa-edit');
let addressElement = this.parentNode.getElementsByClassName('form-control-plaintext')[0];
addressElement.classList.remove('form-control-plaintext', 'text-muted');
addressElement.classList.add('form-control');
addressElement.readOnly = false;
if (addressElement.value == '') {
addressElement.placeholder = 'Email Address';
address.setAttribute('style', 'width: auto; margin-left: 2%;');
}
if (document.getElementById('saveUsers') == null) {
let footer = document.getElementById('userFooter')
let saveUsers = document.createElement('input');
saveUsers.classList.add('btn', 'btn-primary');
saveUsers.setAttribute('type', 'button');
saveUsers.value = 'Save Changes';
saveUsers.id = 'saveUsers';
saveUsers.onclick = function() {
let send = {}
let entries = document.getElementById('userList').children;
for (let entry of entries) {
if (typeof(entry.getElementsByTagName('input')[0]) != 'undefined') {
const name = entry.id.replace(/user_/g, '');
const address = entry.getElementsByTagName('input')[0].value;
send[name] = address;
}
}
send = JSON.stringify(send);
let req = new XMLHttpRequest();
req.open("POST", "/modifyUsers", 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) {
usersModal.hide();
}
}
};
req.send(send);
};
footer.appendChild(saveUsers);
}
};
entry.appendChild(editButton);
entry.appendChild(address);
list.appendChild(entry);
};
let button = document.getElementById('openUsers');
button.disabled = false;
button.innerHTML = 'Users <i class="fa fa-user"></i>';
settingsModal.hide();
usersModal.show();
}
}
};
req.send();
};
generateInvites(empty = true); generateInvites(empty = true);
tryLogin("", "", false, callback = function(code){ tryLogin("", "", false, callback = function(code){

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -65,82 +65,6 @@
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
{{ end }} {{ end }}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style>
.pageContainer {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%;
}
.linkGroup {
/*margin: 20%;*/
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
/*margin: 20%;*/
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
/*margin: 20%;*/
margin-top: 5%;
color: grey;
}
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.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 */
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.settingIcon {
margin-left: 0.2rem;
}
body.modal-open {
overflow: hidden;
}
</style>
<title>Admin</title> <title>Admin</title>
</head> </head>
<body class="smooth-transition"> <body class="smooth-transition">
@ -181,9 +105,6 @@
<button type="button" class="list-group-item list-group-item-action" id="openAbout"> <button type="button" class="list-group-item list-group-item-action" id="openAbout">
About <i class="fa fa-info-circle settingIcon"></i> About <i class="fa fa-info-circle settingIcon"></i>
</button> </button>
<button type="button" class="list-group-item list-group-item-action" id="openUsers">
Email Addresses <i class="fa fa-envelope settingIcon"></i>
</button>
<button type="button" class="list-group-item list-group-item-action" id="openDefaultsWizard"> <button type="button" class="list-group-item list-group-item-action" id="openDefaultsWizard">
New User Defaults <i class="fa fa-user settingIcon"></i> New User Defaults <i class="fa fa-user settingIcon"></i>
</button> </button>
@ -226,16 +147,24 @@
<div class="modal-dialog modal-dialog-centered" role="document"> <div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="defaultsTitle">New user defaults</h5> <h5 class="modal-title" id="defaultsTitle"></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>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.</p> <p id="userDefaultsDescription"></p>
<div class="mb-3" id="defaultsSourceSection">
<label for="defaultsSource">Use settings from:</label>
<select class="form-select" id="defaultsSource" aria-label="User settings source">
<option value="userTemplate" selected>Use existing user template</option>
<option value="fromUser">Source from existing user</option>
</select>
</div>
<div id="defaultUserRadios"></div> <div id="defaultUserRadios"></div>
<div class="checkbox"> <div class="checkbox">
<label><input type="checkbox" value="" style="margin-right: 1rem;" id="storeDefaultHomescreen" checked>Store homescreen layout</label> <input type="checkbox" value="" style="margin-right: 1rem;" id="storeDefaultHomescreen" checked>
<label for="storeDefaultHomescreen" id="storeHomescreenLabel"></label>
</div> </div>
</div> </div>
<div class="modal-footer" id="defaultsFooter"> <div class="modal-footer" id="defaultsFooter">
@ -278,8 +207,8 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button> <button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" id="applyRestarts" data-dismiss="modal">Apply, Restart later</button> <button type="button" class="btn btn-secondary" id="applyrestarts" data-dismiss="modal">apply, restart later</button>
<button type="button" class="btn btn-primary" id="applyAndRestart" data-dismiss="modal">Apply &amp; Restart</button> <button type="button" class="btn btn-primary" id="applyandrestart" data-dismiss="modal">apply &amp; restart</button>
</div> </div>
</div> </div>
</div> </div>
@ -315,10 +244,73 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="deleteModal" role="dialog" aria-labelledby="Account deletion" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalTitle"></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="deleteModalNotify">
<label class="form-check-label" for="deleteModalNotify">Notify users of account deletion</label>
</div>
<div class="mb-3 unfocused" id="deleteModalReasonBox">
<label for="deleteModalReason" class="form-label">Reason for deletion</label>
<textarea class="form-control" id="deleteModalReason" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="deleteModalSend">Delete</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="newUserModal" role="dialog" aria-labelledby="Create new user" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create a user</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newUserEmail" class="form-label">Email address</label>
<input type="email" class="form-control" id="newUserEmail" aria-describedby="Email address">
</div>
{{ if .username }}
<div class="mb-3">
<label for="newUserName" class="form-label">Username</label>
<input type="text" class="form-control" id="newUserName" aria-describedby="Username">
</div>
{{ end }}
<div class="mb-3">
<label for="newUserPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="newUserPassword" aria-describedby="Password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="newUserCreate">Create</button>
</div>
</div>
</div>
</div>
<div class="pageContainer"> <div class="pageContainer">
<h1> <ul class="nav nav-pills" style="margin-bottom: 2rem;">
Accounts admin <li class="nav-item">
</h1> <h2><a id="invitesTabButton" class="nl nav-link active">Invites</a></h2>
</li>
<li class="nav-item">
<h2><a id="accountsTabButton" class="nl nav-link">Accounts</a></h2>
</li>
</ul>
<div class="btn-group" role="group" id="headerButtons"> <div class="btn-group" role="group" id="headerButtons">
<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>
@ -327,76 +319,105 @@
Logout <i class="fa fa-sign-out"></i> Logout <i class="fa fa-sign-out"></i>
</button> </button>
</div> </div>
<div class="card mb-3 linkGroup"> <div id="invitesTab">
<div class="card-header">Current Invites</div> <div class="card mb-3 tabGroup">
<ul class="list-group list-group-flush" id="invites"> <div class="card-header">Current Invites</div>
</ul> <ul class="list-group list-group-flush" id="invites">
</ul>
</div>
<div class="linkForm">
<div class="card mb-3">
<div class="card-header">Generate Invite</div>
<div class="card-body">
<form action="#" method="POST" id="inviteForm" class="container">
<div class="row align-items-start">
<div class="col-sm">
<div class="form-group">
<label for="days">Days</label>
<select class="form-control form-select" id="days" name="days">
</select>
</div>
<div class="form-group">
<label for="hours">Hours</label>
<select class="form-control form-select" id="hours" name="hours">
</select>
</div>
<div class="form-group">
<label for="minutes">Minutes</label>
<select class="form-control form-select" id="minutes" name="minutes">
</select>
</div>
</div>
<div class="col">
<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>
{{ end }}
</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>
</div>
</div>
</div>
</div> </div>
<div class="linkForm"> <div id="accountsTab" class="unfocused">
<div class="card mb-3"> <div class="card mb-3 tabGroup">
<div class="card-header">Generate Invite</div> <div class="card-header d-flex" style="align-items: center;">
<div class="card-body"> <div>Accounts</div>
<form action="#" method="POST" id="inviteForm" class="container"> <div class="ml-auto">
<div class="row align-items-start"> <button type="button" class="btn btn-secondary" id="accountsTabAddUser">Add User</button>
<div class="col-sm"> <button type="button" class="btn btn-primary unfocused" id="accountsTabSetDefaults">Modify Settings</button>
<div class="form-group"> <button type="button" class="btn btn-danger unfocused" id="accountsTabDelete"></button>
<label for="days">Days</label> </div>
<select class="form-control form-select" id="days" name="days"> </div>
</select> <div class="card-body table-responsive">
</div> <table class="table table-hover table-striped table-borderless">
<div class="form-group"> <thead>
<label for="hours">Hours</label> <tr>
<select class="form-control form-select" id="hours" name="hours"> <th scope="col"><input class="form-check-input" type="checkbox" value="" id="selectAll"></th>
</select> <th scope="col">Username</th>
</div> <th scope="col">Email Address</th>
<div class="form-group"> <th scope="col">Last Active</th>
<label for="minutes">Minutes</label> <th scope="col">Admin?</th>
<select class="form-control form-select" id="minutes" name="minutes"> </tr>
</select> </thead>
</div> <tbody id="accountsList">
</div> </tbody>
<div class="col"> </table>
<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>
{{ end }}
</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>
</div> </div>
</div> </div>
</div> </div>
@ -479,6 +500,7 @@
const notifications_enabled = false; const notifications_enabled = false;
{{ end }} {{ end }}
</script> </script>
<script src="accounts.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
</body> </body>
</html> </html>

View File

@ -278,6 +278,32 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
return email, nil return email, nil
} }
func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("deletion").Key("subject").MustString("Your account was deleted - Jellyfin"),
}
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("deletion").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"reason": reason,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
// calls the send method in the underlying emailClient. // calls the send method in the underlying emailClient.
func (emailer *Emailer) send(address string, email *Email) error { func (emailer *Emailer) send(address string, email *Email) error {
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email) return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)

View File

@ -215,6 +215,17 @@ func (jf *Jellyfin) _post(url string, data map[string]interface{}, response bool
return "", resp.StatusCode, nil return "", resp.StatusCode, nil
} }
func (jf *Jellyfin) deleteUser(id string) (int, error) {
url := fmt.Sprintf("%s/Users/%s", jf.server, id)
req, _ := http.NewRequest("DELETE", url, nil)
for name, value := range jf.header {
req.Header.Add(name, value)
}
resp, err := jf.httpClient.Do(req)
defer timeoutHandler("Jellyfin", jf.server, jf.noFail)
return resp.StatusCode, err
}
func (jf *Jellyfin) getUsers(public bool) ([]map[string]interface{}, int, error) { func (jf *Jellyfin) getUsers(public bool) ([]map[string]interface{}, int, error) {
var result []map[string]interface{} var result []map[string]interface{}
var data string var data string

36
mail/deleted.mjml Normal file
View File

@ -0,0 +1,36 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Your account was deleted.</h3>
<p>Reason: <i>{{ .reason }}</i></p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

4
mail/deleted.txt Normal file
View File

@ -0,0 +1,4 @@
Your Jellyfin account was deleted.
Reason: {{ .reason }}
{{ .message }}

View File

@ -322,7 +322,7 @@ func start(asDaemon, firstCall bool) {
app.storage.policy_path = app.config.Section("files").Key("user_template").String() app.storage.policy_path = app.config.Section("files").Key("user_template").String()
app.storage.loadPolicy() app.storage.loadPolicy()
// app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json") // app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json")
app.storage.policy_path = app.config.Section("files").Key("user_configuration").String() app.storage.configuration_path = app.config.Section("files").Key("user_configuration").String()
app.storage.loadConfiguration() app.storage.loadConfiguration()
// app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json") // app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json")
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String() app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
@ -435,13 +435,16 @@ func start(asDaemon, firstCall bool) {
router.GET("/invite/:invCode", app.InviteProxy) router.GET("/invite/:invCode", app.InviteProxy)
api := router.Group("/", app.webAuth()) api := router.Group("/", app.webAuth())
router.POST("/logout", app.Logout) router.POST("/logout", app.Logout)
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("/setNotify", app.SetNotify) api.POST("/setNotify", app.SetNotify)
api.POST("/deleteInvite", app.DeleteInvite) api.POST("/deleteInvite", app.DeleteInvite)
api.POST("/deleteUser", app.DeleteUser)
api.GET("/getUsers", app.GetUsers) api.GET("/getUsers", app.GetUsers)
api.POST("/modifyUsers", app.ModifyEmails) api.POST("/modifyEmails", app.ModifyEmails)
api.POST("/setDefaults", app.SetDefaults) api.POST("/setDefaults", app.SetDefaults)
api.POST("/applySettings", app.ApplySettings)
api.GET("/getConfig", app.GetConfig) api.GET("/getConfig", app.GetConfig)
api.POST("/modifyConfig", app.ModifyConfig) api.POST("/modifyConfig", app.ModifyConfig)
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {

117
scss/base.scss Normal file
View File

@ -0,0 +1,117 @@
.pageContainer {
margin: 5% 20% 5% 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%;
}
.tabGroup {
/*margin: 20%;*/
margin-bottom: 5%;
margin-top: 5%;
}
.linkForm {
/*margin: 20%;*/
margin-top: 5%;
margin-bottom: 5%;
}
.contactBox {
/*margin: 20%;*/
margin-top: 5%;
color: grey;
}
.circle {
/*margin-left: 1rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
z-index: 5000;*/
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeInCubic */
}
.smooth-transition {
-webkit-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-moz-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
-o-transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190);
transition: all 300ms cubic-bezier(0.550, 0.055, 0.675, 0.190); /* easeincubic */
}
.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 */
}
.invite-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: auto;
}
.settingIcon {
margin-left: 0.2rem;
}
body.modal-open {
overflow: hidden;
}
@mixin white-text {
&, &:visited, &:hover, &:active {
font-style: inherit;
color: inherit;
font-size: inherit;
text-decoration: none;
font-variant: inherit;
font-weight: inherit;
line-height: inherit;
font-family: inherit;
}
}
%white-text {
@include white-text;
}
%link-unstyled {
@include white-text;
background-color: transparent;
margin-right: 0.5rem;
}
.text-button {
@extend %link-unstyled;
}
.text-button:hover {
@extend %link-unstyled;
}
.nl {
@extend %link-unstyled;
}
.nl:hover {
@extend %white-text;
}
.unfocused {
display: none;
}

View File

@ -138,3 +138,8 @@ $list-group-action-active-bg: $jf-blue-focus;
background-color: $success; background-color: $success;
} }
.nav-link:hover {
background-color: $jf-blue-hover;
}
@import "../base.scss";

19
scss/bs4/bs4.scss Normal file
View File

@ -0,0 +1,19 @@
@import "../../node_modules/bootstrap4/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@ -138,3 +138,8 @@ $list-group-action-active-bg: $jf-blue-focus;
background-color: $success; background-color: $success;
} }
.nav-link:hover {
background-color: $jf-blue-hover;
}
@import "../base.scss";

19
scss/bs5/bs5.scss Normal file
View File

@ -0,0 +1,19 @@
@import "../../node_modules/bootstrap/scss/bootstrap";
.icon-button {
color: $text-muted;
}
.icon-button:hover {
color: inherit;
}
.icon-button:active {
color: $text-muted;
}
.nav-link:hover {
background-color: $list-group-hover-bg;
}
@import "../base.scss";

View File

@ -39,44 +39,45 @@ if not args.yes:
node_bin = input("input bin directory: ") node_bin = input("input bin directory: ")
for bsv in [d for d in local_path.iterdir() if "bs" in d.name]: for bsv in [d for d in local_path.iterdir() if "bs" in d.name]:
scss = bsv / f"{bsv.name}-jf.scss" scss = [(bsv / f"{bsv.name}-jf.scss"), (bsv / f"{bsv.name}.scss")]
css = bsv / f"{bsv.name}-jf.css" css = [(bsv / f"{bsv.name}-jf.css"), (bsv / f"{bsv.name}.css")]
min_css = bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css" min_css = [(bsv.parents[1] / "data" / "static" / f"{bsv.name}-jf.css"), (bsv.parents[1] / "data" / "static" / f"{bsv.name}.css")]
with open(css, "w") as f: for i in range(2):
f.write( with open(css[i], "w") as f:
sass.compile( f.write(
filename=str(scss.resolve()), output_style="expanded", precision=6 sass.compile(
filename=str(scss[i].resolve()), output_style="expanded", precision=6
)
) )
) if css[i].exists():
if css.exists(): print(f"{scss[i].name}: Compiled.")
print(f"{bsv.name}: Compiled.") # postcss only excepts forwards slashes? weird.
# postcss only excepts forwards slashes? weird. cssPath = str(css[i].resolve())
cssPath = str(css.resolve()) if os.name == "nt":
if os.name == "nt": cssPath = cssPath.replace("\\", "/")
cssPath = cssPath.replace("\\", "/") runcmd(
runcmd( f'{str((node_bin / "postcss").resolve())} {cssPath} --replace --use autoprefixer'
f'{str((node_bin / "postcss").resolve())} {cssPath} --replace --use autoprefixer' )
) print(f"{scss[i].name}: Prefixed.")
print(f"{bsv.name}: Prefixed.") runcmd(
runcmd( f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css[i].resolve())} {str(css[i].resolve())}'
f'{str((node_bin / "cleancss").resolve())} --level 1 --format breakWith=lf --output {str(min_css.resolve())} {str(css.resolve())}' )
) if min_css[i].exists():
if min_css.exists(): print(f"{scss[i].name}: Minified and copied to {str(min_css[i].resolve())}.")
print(f"{bsv.name}: Minified and copied to {str(min_css.resolve())}.")
for v in [("bootstrap", "bs5"), ("bootstrap4", "bs4")]: # for v in [("bootstrap", "bs5"), ("bootstrap4", "bs4")]:
new_path = str((local_path.parent / "data" / "static" / (v[1] + ".css")).resolve()) # new_path = str((local_path.parent / "data" / "static" / (v[1] + ".css")).resolve())
shutil.copy( # shutil.copy(
str( # str(
( # (
local_path.parent # local_path.parent
/ "node_modules" # / "node_modules"
/ v[0] # / v[0]
/ "dist" # / "dist"
/ "css" # / "css"
/ "bootstrap.min.css" # / "bootstrap.min.css"
).resolve() # ).resolve()
), # ),
new_path, # new_path,
) # )
print(f"Copied {v[1]} to {new_path}") # print(f"Copied {v[1]} to {new_path}")

View File

@ -20,6 +20,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"version": VERSION, "version": VERSION,
"commit": COMMIT, "commit": COMMIT,
"ombiEnabled": ombiEnabled, "ombiEnabled": ombiEnabled,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
}) })
} }