Add account deletion with email notification

Select users to delete, then optionally opt to notify the user in an
email with a provided reason.
This commit is contained in:
Harvey Tindall 2020-09-17 23:50:07 +01:00
parent 2b84e45b65
commit 9213f2a078
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
12 changed files with 289 additions and 4 deletions

45
api.go
View File

@ -274,6 +274,51 @@ func (app *appContext) NewUser(gc *gin.Context) {
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 {
Days int `json:"days"`
Hours int `json:"hours"`

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_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)
return nil

View File

@ -543,6 +543,36 @@
"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": {
"meta": {
"name": "File Storage",

View File

@ -10,6 +10,7 @@
"mailgun",
"smtp",
"ombi",
"deletion",
"files"
],
"jellyfin": {
@ -634,6 +635,41 @@
"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": {
"order": [
"invites",

View File

@ -34,6 +34,73 @@ function checkCheckboxes() {
}
}
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) {
@ -68,7 +135,6 @@ function changeEmail(icon, id) {
//this.remove();
let send = {};
send[id] = newEmail;
console.log(send);
let req = new XMLHttpRequest();
req.open("POST", "/modifyEmails", true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
@ -195,7 +261,7 @@ document.getElementById('accountsTabSetDefaults').onclick = function() {
checked = '';
}
radio.innerHTML = `
<label><input type="radio" name="defaultRadios" id="select_${user['id']}" 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);
}
let userstring = 'user';

View File

@ -134,6 +134,7 @@ var usersModal = createModal('users');
var restartModal = createModal('restartModal');
var refreshModal = createModal('refreshModal');
var aboutModal = createModal('aboutModal');
var deleteModal = createModal('deleteModal');
// 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) {
@ -671,7 +672,7 @@ document.getElementById('openDefaultsWizard').onclick = function() {
checked = '';
}
radio.innerHTML =
`<label><input type="radio" name="defaultRadios" id="select_${user['id']}" 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);
}
let button = document.getElementById('openDefaultsWizard');
@ -718,7 +719,7 @@ function storeDefaults(users) {
let id = '';
for (let radio of radios) {
if (radio.checked) {
id = radio.id.replace('select_', '');
id = radio.id.replace('default_', '');
break;
}
}

View File

@ -244,6 +244,32 @@
</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="pageContainer">
<h1><a id="invitesTabButton" class="text-button">invites </a><a id="accountsTabButton" class="text-button text-muted">accounts</a></h1>
<div class="btn-group" role="group" id="headerButtons">

View File

@ -278,6 +278,32 @@ func (emailer *Emailer) constructReset(pwr Pwr, app *appContext) (*Email, error)
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.
func (emailer *Emailer) send(address string, email *Email) error {
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
}
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) {
var result []map[string]interface{}
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

@ -439,6 +439,7 @@ func start(asDaemon, firstCall bool) {
api.GET("/getInvites", app.GetInvites)
api.POST("/setNotify", app.SetNotify)
api.POST("/deleteInvite", app.DeleteInvite)
api.POST("/deleteUser", app.DeleteUser)
api.GET("/getUsers", app.GetUsers)
api.POST("/modifyEmails", app.ModifyEmails)
api.POST("/setDefaults", app.SetDefaults)