mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00:10 +00:00
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:
parent
2b84e45b65
commit
9213f2a078
45
api.go
45
api.go
@ -274,6 +274,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"`
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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 = [];
|
var jfUsers = [];
|
||||||
|
|
||||||
function validEmail(email) {
|
function validEmail(email) {
|
||||||
@ -68,7 +135,6 @@ function changeEmail(icon, id) {
|
|||||||
//this.remove();
|
//this.remove();
|
||||||
let send = {};
|
let send = {};
|
||||||
send[id] = newEmail;
|
send[id] = newEmail;
|
||||||
console.log(send);
|
|
||||||
let req = new XMLHttpRequest();
|
let req = new XMLHttpRequest();
|
||||||
req.open("POST", "/modifyEmails", true);
|
req.open("POST", "/modifyEmails", true);
|
||||||
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
|
||||||
@ -195,7 +261,7 @@ document.getElementById('accountsTabSetDefaults').onclick = function() {
|
|||||||
checked = '';
|
checked = '';
|
||||||
}
|
}
|
||||||
radio.innerHTML = `
|
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);
|
radioList.appendChild(radio);
|
||||||
}
|
}
|
||||||
let userstring = 'user';
|
let userstring = 'user';
|
||||||
|
@ -134,6 +134,7 @@ 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');
|
||||||
|
|
||||||
// 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) {
|
||||||
@ -671,7 +672,7 @@ document.getElementById('openDefaultsWizard').onclick = function() {
|
|||||||
checked = '';
|
checked = '';
|
||||||
}
|
}
|
||||||
radio.innerHTML =
|
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);
|
radioList.appendChild(radio);
|
||||||
}
|
}
|
||||||
let button = document.getElementById('openDefaultsWizard');
|
let button = document.getElementById('openDefaultsWizard');
|
||||||
@ -718,7 +719,7 @@ function storeDefaults(users) {
|
|||||||
let id = '';
|
let id = '';
|
||||||
for (let radio of radios) {
|
for (let radio of radios) {
|
||||||
if (radio.checked) {
|
if (radio.checked) {
|
||||||
id = radio.id.replace('select_', '');
|
id = radio.id.replace('default_', '');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,6 +244,32 @@
|
|||||||
</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">×</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">
|
<div class="pageContainer">
|
||||||
<h1><a id="invitesTabButton" class="text-button">invites </a><a id="accountsTabButton" class="text-button text-muted">accounts</a></h1>
|
<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">
|
<div class="btn-group" role="group" id="headerButtons">
|
||||||
|
26
email.go
26
email.go
@ -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)
|
||||||
|
11
jfapi.go
11
jfapi.go
@ -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
36
mail/deleted.mjml
Normal 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
4
mail/deleted.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
Your Jellyfin account was deleted.
|
||||||
|
Reason: {{ .reason }}
|
||||||
|
|
||||||
|
{{ .message }}
|
1
main.go
1
main.go
@ -439,6 +439,7 @@ func start(asDaemon, firstCall bool) {
|
|||||||
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("/modifyEmails", app.ModifyEmails)
|
api.POST("/modifyEmails", app.ModifyEmails)
|
||||||
api.POST("/setDefaults", app.SetDefaults)
|
api.POST("/setDefaults", app.SetDefaults)
|
||||||
|
Loading…
Reference in New Issue
Block a user