From 55e21f8be3b8d170ea27f61799f676bfc3d542b7 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 12 Apr 2021 21:28:36 +0100 Subject: [PATCH] accounts: add user enable/disable & emails --- api.go | 168 ++++++++++++++++++++++++++++++++-------- config.go | 6 ++ config/config-base.json | 62 +++++++++++++++ email.go | 90 ++++++++++++++++++++- html/admin.html | 1 + lang.go | 2 + lang/admin/en-us.json | 18 +++++ lang/email/de-de.json | 2 +- lang/email/el-gr.json | 2 +- lang/email/en-us.json | 16 +++- lang/email/fr-fr.json | 2 +- lang/email/id-id.json | 2 +- lang/email/it-it.json | 6 +- lang/email/nl-nl.json | 2 +- lang/email/pt-br.json | 2 +- lang/email/sv-se.json | 6 +- mail/deleted.mjml | 2 +- mail/deleted.txt | 2 +- models.go | 7 ++ router.go | 1 + storage.go | 5 ++ ts/modules/accounts.ts | 90 ++++++++++++++++++++- ts/modules/invites.ts | 1 + 23 files changed, 437 insertions(+), 58 deletions(-) diff --git a/api.go b/api.go index 85f66b9..1047d29 100644 --- a/api.go +++ b/api.go @@ -573,6 +573,70 @@ func (app *appContext) Announce(gc *gin.Context) { respondBool(200, true, gc) } +// @Summary Enable/Disable a list of users, optionally notifying them why. +// @Produce json +// @Param enableDisableUserDTO body enableDisableUserDTO true "User enable/disable request object" +// @Success 200 {object} boolResponse +// @Failure 400 {object} stringResponse +// @Failure 500 {object} errorListDTO "List of errors" +// @Router /users/enable [post] +// @Security Bearer +// @tags Users +func (app *appContext) EnableDisableUsers(gc *gin.Context) { + var req enableDisableUserDTO + gc.BindJSON(&req) + errors := errorListDTO{ + "GetUser": map[string]string{}, + "SetPolicy": map[string]string{}, + } + var addresses []string + for _, userID := range req.Users { + user, status, err := app.jf.UserByID(userID, false) + if status != 200 || err != nil { + errors["GetUser"][userID] = fmt.Sprintf("%d %v", status, err) + app.err.Printf("Failed to get user \"%s\" (%d): %v", userID, status, err) + continue + } + user.Policy.IsDisabled = !req.Enabled + status, err = app.jf.SetPolicy(userID, user.Policy) + if !(status == 200 || status == 204) || err != nil { + errors["SetPolicy"][userID] = fmt.Sprintf("%d %v", status, err) + app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) + continue + } + if emailEnabled && req.Notify { + addr, ok := app.storage.emails[userID] + if addr != nil && ok { + addresses = append(addresses, addr.(string)) + } + } + } + if len(addresses) != 0 { + go func(reason string, addresses []string) { + var msg *Email + var err error + if req.Enabled { + msg, err = app.email.constructEnabled(reason, app, false) + } else { + msg, err = app.email.constructDisabled(reason, app, false) + } + if err != nil { + app.err.Printf("Failed to construct account enabled/disabled emails: %v", err) + } else if err := app.email.send(msg, addresses...); err != nil { + app.err.Printf("Failed to send account enabled/disabled emails: %v", err) + } else { + app.info.Println("Sent account enabled/disabled emails") + } + }(req.Reason, addresses) + } + app.jf.CacheExpiry = time.Now() + if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 { + gc.JSON(500, errors) + return + } + respondBool(200, true, gc) +} + // @Summary Delete a list of users, optionally notifying them why. // @Produce json // @Param deleteUserDTO body deleteUserDTO true "User deletion request object" @@ -1379,6 +1443,8 @@ func (app *appContext) GetEmails(gc *gin.Context) { "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled}, "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled}, "UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled}, + "UserDisabled": {Name: app.storage.lang.Email[lang].UserDisabled["name"], Enabled: app.storage.customEmails.UserDisabled.Enabled}, + "UserEnabled": {Name: app.storage.lang.Email[lang].UserEnabled["name"], Enabled: app.storage.customEmails.UserEnabled.Enabled}, "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled}, "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled}, @@ -1414,6 +1480,12 @@ func (app *appContext) SetEmail(gc *gin.Context) { } else if id == "UserDeleted" { app.storage.customEmails.UserDeleted.Content = req.Content app.storage.customEmails.UserDeleted.Enabled = true + } else if id == "UserDisabled" { + app.storage.customEmails.UserDisabled.Content = req.Content + app.storage.customEmails.UserDisabled.Enabled = true + } else if id == "UserEnabled" { + app.storage.customEmails.UserEnabled.Content = req.Content + app.storage.customEmails.UserEnabled.Enabled = true } else if id == "InviteEmail" { app.storage.customEmails.InviteEmail.Content = req.Content app.storage.customEmails.InviteEmail.Enabled = true @@ -1461,6 +1533,10 @@ func (app *appContext) SetEmailState(gc *gin.Context) { app.storage.customEmails.PasswordReset.Enabled = enabled } else if id == "UserDeleted" { app.storage.customEmails.UserDeleted.Enabled = enabled + } else if id == "UserDisabled" { + app.storage.customEmails.UserDisabled.Enabled = enabled + } else if id == "UserEnabled" { + app.storage.customEmails.UserEnabled.Enabled = enabled } else if id == "InviteEmail" { app.storage.customEmails.InviteEmail.Enabled = enabled } else if id == "WelcomeEmail" { @@ -1480,39 +1556,6 @@ func (app *appContext) SetEmailState(gc *gin.Context) { respondBool(200, true, gc) } -// @Summary Returns whether there's a new update, and extra info if there is. -// @Produce json -// @Success 200 {object} checkUpdateDTO -// @Router /config/update [get] -// @tags Configuration -func (app *appContext) CheckUpdate(gc *gin.Context) { - if !app.newUpdate { - app.update = Update{} - } - gc.JSON(200, checkUpdateDTO{New: app.newUpdate, Update: app.update}) -} - -// @Summary Apply an update. -// @Produce json -// @Success 200 {object} boolResponse -// @Success 400 {object} stringResponse -// @Success 500 {object} boolResponse -// @Router /config/update [post] -// @tags Configuration -func (app *appContext) ApplyUpdate(gc *gin.Context) { - if !app.update.CanUpdate { - respond(400, "Update is manual", gc) - return - } - err := app.update.update() - if err != nil { - app.err.Printf("Failed to apply update: %v", err) - respondBool(500, false, gc) - return - } - respondBool(200, true, gc) -} - // @Summary Returns the custom email (generating it if not set) and list of used variables in it. // @Produce json // @Success 200 {object} customEmailDTO @@ -1578,8 +1621,32 @@ func (app *appContext) GetEmail(gc *gin.Context) { variables = app.storage.customEmails.UserDeleted.Variables } writeVars = func(variables []string) { app.storage.customEmails.UserDeleted.Variables = variables } - values = app.email.deletedValues(app.storage.lang.Email[lang].UserDeleted.get("reason"), app, false) + values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) // app.storage.customEmails.UserDeleted = content + } else if id == "UserDisabled" { + content = app.storage.customEmails.UserDisabled.Content + if content == "" { + newEmail = true + msg, err = app.email.constructDisabled("", app, true) + content = msg.Text + } else { + variables = app.storage.customEmails.UserDisabled.Variables + } + writeVars = func(variables []string) { app.storage.customEmails.UserDisabled.Variables = variables } + values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) + // app.storage.customEmails.UserDeleted = content + } else if id == "UserEnabled" { + content = app.storage.customEmails.UserEnabled.Content + if content == "" { + newEmail = true + msg, err = app.email.constructEnabled("", app, true) + content = msg.Text + } else { + variables = app.storage.customEmails.UserEnabled.Variables + } + writeVars = func(variables []string) { app.storage.customEmails.UserEnabled.Variables = variables } + values = app.email.deletedValues(app.storage.lang.Email[lang].Strings.get("reason"), app, false) + // app.storage.customEmails.UserEnabled = content } else if id == "InviteEmail" { content = app.storage.customEmails.InviteEmail.Content if content == "" { @@ -1670,6 +1737,39 @@ func (app *appContext) GetEmail(gc *gin.Context) { gc.JSON(200, customEmailDTO{Content: content, Variables: variables, Values: values, HTML: email.HTML, Plaintext: email.Text}) } +// @Summary Returns whether there's a new update, and extra info if there is. +// @Produce json +// @Success 200 {object} checkUpdateDTO +// @Router /config/update [get] +// @tags Configuration +func (app *appContext) CheckUpdate(gc *gin.Context) { + if !app.newUpdate { + app.update = Update{} + } + gc.JSON(200, checkUpdateDTO{New: app.newUpdate, Update: app.update}) +} + +// @Summary Apply an update. +// @Produce json +// @Success 200 {object} boolResponse +// @Success 400 {object} stringResponse +// @Success 500 {object} boolResponse +// @Router /config/update [post] +// @tags Configuration +func (app *appContext) ApplyUpdate(gc *gin.Context) { + if !app.update.CanUpdate { + respond(400, "Update is manual", gc) + return + } + err := app.update.update() + if err != nil { + app.err.Printf("Failed to apply update: %v", err) + respondBool(500, false, gc) + return + } + respondBool(200, true, gc) +} + // @Summary Logout by deleting refresh token from cookies. // @Produce json // @Success 200 {object} boolResponse diff --git a/config.go b/config.go index 860839f..7b3092e 100644 --- a/config.go +++ b/config.go @@ -64,6 +64,12 @@ func (app *appContext) loadConfig() error { app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html") app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt") + // Deletion template is good enough for these as well. + app.MustSetValue("disable_enable", "disabled_html", "jfa-go:"+"deleted.html") + app.MustSetValue("disable_enable", "disabled_text", "jfa-go:"+"deleted.txt") + app.MustSetValue("disable_enable", "enabled_html", "jfa-go:"+"deleted.html") + app.MustSetValue("disable_enable", "enabled_text", "jfa-go:"+"deleted.txt") + app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html") app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt") diff --git a/config/config-base.json b/config/config-base.json index 87b9283..71c0f97 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -899,6 +899,68 @@ } } }, + "disable_enable": { + "order": [], + "meta": { + "name": "Account Disabling/Enabling", + "description": "Subject/email files for account disabling/enabling emails.", + "depends_true": "email|method" + }, + "settings": { + "subject_disabled": { + "name": "Email subject (Disabled)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Subject of account disabling emails." + }, + "subject_enabled": { + "name": "Email subject (Enabled)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Subject of account enabling emails." + }, + "disabled_html": { + "name": "Custom disabling email (HTML)", + "required": false, + "requires_restart": false, + "advanced": true, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "disabled_text": { + "name": "Custom disabling email (plaintext)", + "required": false, + "requires_restart": false, + "advanced": true, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + }, + "enabled_html": { + "name": "Custom enabling email (HTML)", + "required": false, + "requires_restart": false, + "advanced": true, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "enabled_text": { + "name": "Custom enabling email (plaintext)", + "required": false, + "requires_restart": false, + "advanced": true, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + } + } + }, "deletion": { "order": [], "meta": { diff --git a/email.go b/email.go index 68dc90b..ee86a27 100644 --- a/email.go +++ b/email.go @@ -535,9 +535,9 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool) map[string]interface{} { template := map[string]interface{}{ - "yourAccountWasDeleted": emailer.lang.UserDeleted.get("yourAccountWasDeleted"), - "reasonString": emailer.lang.UserDeleted.get("reason"), - "message": "", + "yourAccountWas": emailer.lang.UserDeleted.get("yourAccountWasDeleted"), + "reasonString": emailer.lang.Strings.get("reason"), + "message": "", } if noSub { empty := []string{"reason"} @@ -575,6 +575,90 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b return email, nil } +func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub bool) map[string]interface{} { + template := map[string]interface{}{ + "yourAccountWas": emailer.lang.UserDisabled.get("yourAccountWasDisabled"), + "reasonString": emailer.lang.Strings.get("reason"), + "message": "", + } + if noSub { + empty := []string{"reason"} + for _, v := range empty { + template[v] = "{" + v + "}" + } + } else { + template["reason"] = reason + template["message"] = app.config.Section("email").Key("message").String() + } + return template +} + +func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Email, error) { + email := &Email{ + Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), + } + var err error + template := emailer.disabledValues(reason, app, noSub) + if app.storage.customEmails.UserDisabled.Enabled { + content := app.storage.customEmails.UserDisabled.Content + for _, v := range app.storage.customEmails.UserDisabled.Variables { + replaceWith, ok := template[v[1:len(v)-1]] + if ok { + content = strings.ReplaceAll(content, v, replaceWith.(string)) + } + } + email, err = emailer.constructTemplate(email.Subject, content, app) + } else { + email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "disabled_", template) + } + if err != nil { + return nil, err + } + return email, nil +} + +func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool) map[string]interface{} { + template := map[string]interface{}{ + "yourAccountWas": emailer.lang.UserEnabled.get("yourAccountWasEnabled"), + "reasonString": emailer.lang.Strings.get("reason"), + "message": "", + } + if noSub { + empty := []string{"reason"} + for _, v := range empty { + template[v] = "{" + v + "}" + } + } else { + template["reason"] = reason + template["message"] = app.config.Section("email").Key("message").String() + } + return template +} + +func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Email, error) { + email := &Email{ + Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), + } + var err error + template := emailer.enabledValues(reason, app, noSub) + if app.storage.customEmails.UserEnabled.Enabled { + content := app.storage.customEmails.UserEnabled.Content + for _, v := range app.storage.customEmails.UserEnabled.Variables { + replaceWith, ok := template[v[1:len(v)-1]] + if ok { + content = strings.ReplaceAll(content, v, replaceWith.(string)) + } + } + email, err = emailer.constructTemplate(email.Subject, content, app) + } else { + email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "enabled_", template) + } + if err != nil { + return nil, err + } + return email, nil +} + func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} { template := map[string]interface{}{ "welcome": emailer.lang.WelcomeEmail.get("welcome"), diff --git a/html/admin.html b/html/admin.html index 37a07d4..95fbd50 100644 --- a/html/admin.html +++ b/html/admin.html @@ -471,6 +471,7 @@ {{ .strings.announce }} {{ .strings.modifySettings }} {{ .strings.extendExpiry }} + {{ .strings.disable }} {{ .quantityStrings.deleteUser.Singular }} diff --git a/lang.go b/lang.go index beddaab..ad9c355 100644 --- a/lang.go +++ b/lang.go @@ -93,6 +93,8 @@ type emailLang struct { InviteExpiry langSection `json:"inviteExpiry"` PasswordReset langSection `json:"passwordReset"` UserDeleted langSection `json:"userDeleted"` + UserDisabled langSection `json:"userDisabled"` + UserEnabled langSection `json:"userEnabled"` InviteEmail langSection `json:"inviteEmail"` WelcomeEmail langSection `json:"welcomeEmail"` EmailConfirmation langSection `json:"emailConfirmation"` diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index f35ae02..bcb023b 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -24,6 +24,8 @@ "date": "Date", "enabled": "Enabled", "disabled": "Disabled", + "reEnable": "Re-enable", + "disable": "Disable", "admin": "Admin", "updates": "Updates", "update": "Update", @@ -136,6 +138,14 @@ "singular": "Delete {n} user", "plural": "Delete {n} users" }, + "disableUsers": { + "singular": "Disable {n} user", + "plural": "Disable {n} users" + }, + "reEnableUsers": { + "singular": "Re-enable {n} user", + "plural": "Re-enable {n} users" + }, "addUser": { "singular": "Add user", "plural": "Add users" @@ -148,6 +158,14 @@ "singular": "Deleted {n} user.", "plural": "Deleted {n} users." }, + "disabledUser": { + "singular": "Disabled {n} user.", + "plural": "Disabled {n} users." + }, + "enabledUser": { + "singular": "Enabled {n} user.", + "plural": "Enabled {n} users." + }, "announceTo": { "singular": "Announce to {n} user", "plural": "Announce to {n} users" diff --git a/lang/email/de-de.json b/lang/email/de-de.json index 02590c0..2d7c20f 100644 --- a/lang/email/de-de.json +++ b/lang/email/de-de.json @@ -4,6 +4,7 @@ }, "strings": { "ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.", + "reason": "Grund", "helloUser": "Hallo {username}," }, "userCreated": { @@ -31,7 +32,6 @@ "userDeleted": { "title": "Dein Konto wurde gelöscht - Jellyfin", "yourAccountWasDeleted": "Dein Jellyfin-Konto wurde gelöscht.", - "reason": "Grund", "name": "Benutzerlöschung" }, "inviteEmail": { diff --git a/lang/email/el-gr.json b/lang/email/el-gr.json index 05b0330..c201853 100644 --- a/lang/email/el-gr.json +++ b/lang/email/el-gr.json @@ -4,6 +4,7 @@ }, "strings": { "ifItWasNotYou": "Αν δεν ήσασταν εσείς, παρακαλώ αγνοήστε αυτό το email.", + "reason": "Λόγος", "helloUser": "Γεία σου {username}," }, "userCreated": { @@ -32,7 +33,6 @@ "userDeleted": { "title": "Ο λογαριασμός σας διαγράφηκε - Jellyfin", "yourAccountWasDeleted": "Ο λογαριασμός σας Jellyfin διαγράφηκε.", - "reason": "Λόγος", "name": "Διαγραφή χρήστη" }, "inviteEmail": { diff --git a/lang/email/en-us.json b/lang/email/en-us.json index c2d6285..78362a3 100644 --- a/lang/email/en-us.json +++ b/lang/email/en-us.json @@ -4,7 +4,8 @@ }, "strings": { "ifItWasNotYou": "If this wasn't you, please ignore this email.", - "helloUser": "Hi {username}," + "helloUser": "Hi {username},", + "reason": "Reason" }, "userCreated": { "name": "User creation", @@ -32,8 +33,17 @@ "userDeleted": { "name": "User deletion", "title": "Your account was deleted - Jellyfin", - "yourAccountWasDeleted": "Your Jellyfin account was deleted.", - "reason": "Reason" + "yourAccountWasDeleted": "Your Jellyfin account was deleted." + }, + "userDisabled": { + "name": "User disabled", + "title": "Your account has been disabled - Jellyfin", + "yourAccountWasDisabled": "Your account was disabled." + }, + "userEnabled": { + "name": "User enabled", + "title": "Your account has been re-enabled - Jellyfin", + "yourAccountWasEnabled": "Your account was re-enabled." }, "inviteEmail": { "name": "Invite email", diff --git a/lang/email/fr-fr.json b/lang/email/fr-fr.json index 361747f..ef484c6 100644 --- a/lang/email/fr-fr.json +++ b/lang/email/fr-fr.json @@ -5,6 +5,7 @@ }, "strings": { "ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.", + "reason": "Motif", "helloUser": "Salut {username}," }, "userCreated": { @@ -32,7 +33,6 @@ "userDeleted": { "title": "Ton compte a été désactivé - Jellyfin", "yourAccountWasDeleted": "Ton compte Jellyfin a été supprimé.", - "reason": "Motif", "name": "Suppression de l'utilisateur" }, "inviteEmail": { diff --git a/lang/email/id-id.json b/lang/email/id-id.json index 06ccceb..7fd4916 100644 --- a/lang/email/id-id.json +++ b/lang/email/id-id.json @@ -4,6 +4,7 @@ }, "strings": { "ifItWasNotYou": "Jika ini bukan kamu, silahkan mengabaikan email ini.", + "reason": "Alasan", "helloUser": "Halo {username}," }, "userCreated": { @@ -31,7 +32,6 @@ "userDeleted": { "title": "Akun anda telah dihapus - Jellyfin", "yourAccountWasDeleted": "Akun Jellyfin anda telah dihapus.", - "reason": "Alasan", "name": "Penghapusan pengguna" }, "inviteEmail": { diff --git a/lang/email/it-it.json b/lang/email/it-it.json index cfeda1c..fe844bc 100644 --- a/lang/email/it-it.json +++ b/lang/email/it-it.json @@ -4,7 +4,8 @@ }, "strings": { "ifItWasNotYou": "Se non sei stato tu, puoi ignorare questa email.", - "helloUser": "Ciao {username}," + "helloUser": "Ciao {username},", + "reason": "Motivo" }, "userCreated": { "title": "Nota: Utente creato", @@ -27,8 +28,7 @@ }, "userDeleted": { "title": "Il tuo account è stato eliminato - Jellyfin", - "yourAccountWasDeleted": "Il tuo account di Jellyfin è stato eliminato.", - "reason": "Motivo" + "yourAccountWasDeleted": "Il tuo account di Jellyfin è stato eliminato." }, "inviteEmail": { "title": "Invita - Jellyfin", diff --git a/lang/email/nl-nl.json b/lang/email/nl-nl.json index 64f518a..7ab0648 100644 --- a/lang/email/nl-nl.json +++ b/lang/email/nl-nl.json @@ -4,6 +4,7 @@ }, "strings": { "ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.", + "reason": "Reden", "helloUser": "Hoi {username}," }, "userCreated": { @@ -32,7 +33,6 @@ "userDeleted": { "title": "Je account is verwijderd - Jellyfin", "yourAccountWasDeleted": "Je Jellyfin account is verwijderd.", - "reason": "Reden", "name": "Gebruiker verwijderd" }, "inviteEmail": { diff --git a/lang/email/pt-br.json b/lang/email/pt-br.json index 0c7f435..84e3bec 100644 --- a/lang/email/pt-br.json +++ b/lang/email/pt-br.json @@ -4,6 +4,7 @@ }, "strings": { "ifItWasNotYou": "Se não foi você, ignore este e-mail.", + "reason": "Razão", "helloUser": "Ola {username}," }, "userCreated": { @@ -32,7 +33,6 @@ "userDeleted": { "title": "Sua conta foi excluída - Jellyfin", "yourAccountWasDeleted": "Sua conta Jellyfin foi excluída.", - "reason": "Razão", "name": "Exclusão do usuário" }, "inviteEmail": { diff --git a/lang/email/sv-se.json b/lang/email/sv-se.json index 8d7317c..d37450f 100644 --- a/lang/email/sv-se.json +++ b/lang/email/sv-se.json @@ -4,7 +4,8 @@ }, "strings": { "ifItWasNotYou": "Om detta inte var du, ignorera det här e-postmeddelandet.", - "helloUser": "Hej {användarnamn}," + "helloUser": "Hej {username},", + "reason": "Anledning" }, "userCreated": { "name": "Användarskapande", @@ -31,8 +32,7 @@ "userDeleted": { "name": "Radering av användare", "title": "Ditt konto raderades - Jellyfin", - "yourAccountWasDeleted": "Ditt Jellyfin-konto raderades.", - "reason": "Anledning" + "yourAccountWasDeleted": "Ditt Jellyfin-konto raderades." }, "inviteEmail": { "name": "Inbjudnings e-post", diff --git a/mail/deleted.mjml b/mail/deleted.mjml index e41c10c..28f3e72 100644 --- a/mail/deleted.mjml +++ b/mail/deleted.mjml @@ -60,7 +60,7 @@ -

{{ .yourAccountWasDeleted }}

+

{{ .yourAccountWas }}

{{ .reasonString }}: {{ .reason }}

diff --git a/mail/deleted.txt b/mail/deleted.txt index 342a95c..a6e90c4 100644 --- a/mail/deleted.txt +++ b/mail/deleted.txt @@ -1,4 +1,4 @@ -{{ .yourAccountWasDeleted }} +{{ .yourAccountWas }} {{ .reasonString }}: {{ .reason }} diff --git a/models.go b/models.go index 724d45d..71ed26d 100644 --- a/models.go +++ b/models.go @@ -29,6 +29,13 @@ type deleteUserDTO struct { Reason string `json:"reason"` // Account deletion reason (for notification) } +type enableDisableUserDTO struct { + Users []string `json:"users" binding:"required"` // List of usernames to delete + Enabled bool `json:"enabled"` // True = enable users, False = disable. + Notify bool `json:"notify"` // Whether to notify users of deletion + Reason string `json:"reason"` // Account deletion reason (for notification) +} + type generateInviteDTO struct { Months int `json:"months" example:"0"` // Number of months Days int `json:"days" example:"1"` // Number of days diff --git a/router.go b/router.go index f28d378..e126048 100644 --- a/router.go +++ b/router.go @@ -132,6 +132,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/users", app.GetUsers) api.POST(p+"/users", app.NewUserAdmin) api.POST(p+"/users/extend", app.ExtendExpiry) + api.POST(p+"/users/enable", app.EnableDisableUsers) api.POST(p+"/invites", app.GenerateInvite) api.GET(p+"/invites", app.GetInvites) api.DELETE(p+"/invites", app.DeleteInvite) diff --git a/storage.go b/storage.go index cbe823d..974a3ce 100644 --- a/storage.go +++ b/storage.go @@ -34,6 +34,8 @@ type customEmails struct { InviteExpiry customEmail `json:"inviteExpiry"` PasswordReset customEmail `json:"passwordReset"` UserDeleted customEmail `json:"userDeleted"` + UserDisabled customEmail `json:"userDisabled"` + UserEnabled customEmail `json:"userEnabled"` InviteEmail customEmail `json:"inviteEmail"` WelcomeEmail customEmail `json:"welcomeEmail"` EmailConfirmation customEmail `json:"emailConfirmation"` @@ -431,10 +433,13 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { patchLang(&english.InviteExpiry, &lang.InviteExpiry) patchLang(&english.PasswordReset, &lang.PasswordReset) patchLang(&english.UserDeleted, &lang.UserDeleted) + patchLang(&english.UserDisabled, &lang.UserDisabled) + patchLang(&english.UserEnabled, &lang.UserEnabled) patchLang(&english.InviteEmail, &lang.InviteEmail) patchLang(&english.WelcomeEmail, &lang.WelcomeEmail) patchLang(&english.EmailConfirmation, &lang.EmailConfirmation) patchLang(&english.UserExpired, &lang.UserExpired) + patchLang(&english.Strings, &lang.Strings) } st.lang.Email[index] = lang return nil diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 7855d7b..579335d 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -194,6 +194,7 @@ export class accountsList { private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement; private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement; private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement; + private _disableEnable = document.getElementById("accounts-disable-enable") as HTMLSpanElement; private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement; private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement; private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement; @@ -209,6 +210,8 @@ export class accountsList { private _sortedByName: string[] = []; private _checkCount: number = 0; private _inSearch = false; + // Whether the enable/disable button should enable or not. + private _shouldEnable = false; private _addUserForm = document.getElementById("form-add-user") as HTMLFormElement; private _addUserName = this._addUserForm.querySelector("input[type=text]") as HTMLInputElement; @@ -329,6 +332,7 @@ export class accountsList { this._announceButton.classList.add("unfocused"); } this._extendExpiry.classList.add("unfocused"); + this._disableEnable.classList.add("unfocused"); } else { let visibleCount = 0; for (let id in this._users) { @@ -350,16 +354,37 @@ export class accountsList { this._announceButton.classList.remove("unfocused"); } let anyNonExpiries = list.length == 0 ? true : false; + // Only show enable/disable button if all selected have the same state. + this._shouldEnable = this._users[list[0]].disabled + let showDisableEnable = true; for (let id of list) { - if (!this._users[id].expiry) { + if (!anyNonExpiries && !this._users[id].expiry) { anyNonExpiries = true; this._extendExpiry.classList.add("unfocused"); - break; } + if (showDisableEnable && this._users[id].disabled != this._shouldEnable) { + showDisableEnable = false; + this._disableEnable.classList.add("unfocused"); + } + if (!showDisableEnable && anyNonExpiries) { break; } } if (!anyNonExpiries) { this._extendExpiry.classList.remove("unfocused"); } + if (showDisableEnable) { + let message: string; + if (this._shouldEnable) { + message = window.lang.strings("reEnable"); + this._disableEnable.classList.add("~positive"); + this._disableEnable.classList.remove("~warning"); + } else { + message = window.lang.strings("disable"); + this._disableEnable.classList.add("~warning"); + this._disableEnable.classList.remove("~positive"); + } + this._disableEnable.classList.remove("unfocused"); + this._disableEnable.textContent = message; + } } } @@ -440,13 +465,67 @@ export class accountsList { }; window.modals.announce.show(); } + + enableDisableUsers = () => { + // We can share the delete modal for this + const modalHeader = document.getElementById("header-delete-user"); + const form = document.getElementById("form-delete-user") as HTMLFormElement; + const button = form.querySelector("span.submit") as HTMLSpanElement; + let list = this._collectUsers(); + if (this._shouldEnable) { + modalHeader.textContent = window.lang.quantity("reEnableUsers", list.length); + button.textContent = window.lang.strings("reEnable"); + button.classList.add("~urge"); + button.classList.remove("~critical"); + } else { + modalHeader.textContent = window.lang.quantity("disableUsers", list.length); + button.textContent = window.lang.strings("disable"); + button.classList.add("~critical"); + button.classList.remove("~urge"); + } + this._deleteNotify.checked = false; + this._deleteReason.value = ""; + this._deleteReason.classList.add("unfocused"); + form.onsubmit = (event: Event) => { + event.preventDefault(); + toggleLoader(button); + let send = { + "users": list, + "enabled": this._shouldEnable, + "notify": this._deleteNotify.checked, + "reason": this._deleteNotify ? this._deleteReason.value : "" + }; + _post("/users/enable", send, (req: XMLHttpRequest) => { + if (req.readyState == 4) { + toggleLoader(button); + window.modals.deleteUser.close(); + if (req.status != 200 && req.status != 204) { + let errorMsg = window.lang.notif("errorFailureCheckLogs"); + if (!("error" in req.response)) { + errorMsg = window.lang.notif("errorPartialFailureCheckLogs"); + } + window.notifications.customError("deleteUserError", errorMsg); + } else if (this._shouldEnable) { + window.notifications.customSuccess("enableUserSuccess", window.lang.quantity("enabledUser", list.length)); + } else { + window.notifications.customSuccess("disableUserSuccess", window.lang.quantity("disabledUser", list.length)); + } + this.reload(); + } + }, true); + } + window.modals.deleteUser.show(); + } deleteUsers = () => { const modalHeader = document.getElementById("header-delete-user"); - modalHeader.textContent = window.lang.quantity("deleteNUsers", this._collectUsers().length); let list = this._collectUsers(); + modalHeader.textContent = window.lang.quantity("deleteNUsers", list.length); const form = document.getElementById("form-delete-user") as HTMLFormElement; const button = form.querySelector("span.submit") as HTMLSpanElement; + button.textContent = window.lang.strings("delete"); + button.classList.add("~critical"); + button.classList.remove("~urge"); this._deleteNotify.checked = false; this._deleteReason.value = ""; this._deleteReason.classList.add("unfocused"); @@ -469,7 +548,7 @@ export class accountsList { } window.notifications.customError("deleteUserError", errorMsg); } else { - window.notifications.customSuccess("deleteUserSuccess", window.lang.quantity("deletedUser", this._collectUsers().length)); + window.notifications.customSuccess("deleteUserSuccess", window.lang.quantity("deletedUser", list.length)); } this.reload(); } @@ -630,6 +709,9 @@ export class accountsList { this._extendExpiry.onclick = this.extendExpiry; this._extendExpiry.classList.add("unfocused"); + this._disableEnable.onclick = this.enableDisableUsers; + this._disableEnable.classList.add("unfocused"); + if (!window.usernameEnabled) { this._addUserName.classList.add("unfocused"); this._addUserName = this._addUserEmail; diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index 913e3e7..f8c86ed 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -793,6 +793,7 @@ export class createInvite { this._invDurationButton.onchange = checkDuration; this._days.onchange = this._checkDurationValidity; + this._months.onchange = this._checkDurationValidity; this._hours.onchange = this._checkDurationValidity; this._minutes.onchange = this._checkDurationValidity; document.addEventListener("profileLoadEvent", () => { this.loadProfiles(); }, false);