1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-10-18 17:10:11 +00:00

Compare commits

..

4 Commits

Author SHA1 Message Date
fa7da1b23f
Don't use cache to ApplySettings and CreateProfile
also use a wrapper function to set default settings in config.go so it's
less ugly.
2021-02-28 18:26:22 +00:00
1ec5d2ca3f
add disabled badge, extend expiry button to accounts 2021-02-28 17:52:24 +00:00
1e9d184508
implement user expiry functionality
All works now, but i'll add a field on the accounts tab for users with
an expiry, as well as a 'disabled' badge.
2021-02-28 15:41:06 +00:00
2934832a98
implement frontend for user expiry/duration
this will add an optional validity period to users, where their account
will be disabled (or deleted) a specified amount of time after they
created it.
2021-02-28 00:44:28 +00:00
28 changed files with 873 additions and 112 deletions

99
api.go
View File

@ -445,10 +445,48 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email) app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email)
} }
} }
if invite.UserExpiry {
expiry := time.Now().Add(time.Duration(60*(invite.UserDays*24+invite.UserHours)+invite.UserMinutes) * time.Minute)
app.storage.users[id] = expiry
err := app.storage.storeUsers()
if err != nil {
app.err.Printf("Failed to store user duration: %s", err)
}
}
success = true success = true
return return
} }
// @Summary Extend time before the user(s) expiry.
// @Produce json
// @Param extendExpiryDTO body extendExpiryDTO true "Extend expiry object"
// @Success 200 {object} boolResponse
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/extend [post]
// @tags Users
func (app *appContext) ExtendExpiry(gc *gin.Context) {
var req extendExpiryDTO
app.info.Printf("Expiry extension requested for %d user(s)", len(req.Users))
gc.BindJSON(&req)
if req.Days == 0 && req.Hours == 0 && req.Minutes == 0 {
respondBool(400, false, gc)
return
}
for _, id := range req.Users {
if expiry, ok := app.storage.users[id]; ok {
app.storage.users[id] = expiry.Add(time.Duration(60*(req.Days*24+req.Hours)+req.Minutes) * time.Minute)
app.debug.Printf("Expiry extended for \"%s\"", id)
}
}
if err := app.storage.storeUsers(); err != nil {
app.err.Printf("Failed to store user duration: %s", err)
respondBool(500, false, gc)
return
}
respondBool(204, true, gc)
}
// @Summary Creates a new Jellyfin user via invite code // @Summary Creates a new Jellyfin user via invite code
// @Produce json // @Produce json
// @Param newUserDTO body newUserDTO true "New user request object" // @Param newUserDTO body newUserDTO true "New user request object"
@ -634,6 +672,12 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
} else { } else {
invite.RemainingUses = 1 invite.RemainingUses = 1
} }
invite.UserExpiry = req.UserExpiry
if invite.UserExpiry {
invite.UserDays = req.UserDays
invite.UserHours = req.UserHours
invite.UserMinutes = req.UserMinutes
}
invite.ValidTill = validTill invite.ValidTill = validTill
if emailEnabled && req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) { if emailEnabled && req.Email != "" && app.config.Section("invite_emails").Key("enabled").MustBool(false) {
app.debug.Printf("%s: Sending invite email", inviteCode) app.debug.Printf("%s: Sending invite email", inviteCode)
@ -750,6 +794,7 @@ func (app *appContext) CreateProfile(gc *gin.Context) {
app.info.Println("Profile creation requested") app.info.Println("Profile creation requested")
var req newProfileDTO var req newProfileDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.jf.CacheExpiry = time.Now()
user, status, err := app.jf.UserByID(req.ID, false) user, status, err := app.jf.UserByID(req.ID, false)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to get user from Jellyfin (%d): %s", status, err) app.err.Printf("Failed to get user from Jellyfin (%d): %s", status, err)
@ -817,6 +862,10 @@ func (app *appContext) GetInvites(gc *gin.Context) {
Days: days, Days: days,
Hours: hours, Hours: hours,
Minutes: minutes, Minutes: minutes,
UserExpiry: inv.UserExpiry,
UserDays: inv.UserDays,
UserHours: inv.UserHours,
UserMinutes: inv.UserMinutes,
Created: app.formatDatetime(inv.Created), Created: app.formatDatetime(inv.Created),
Profile: inv.Profile, Profile: inv.Profile,
NoLimit: inv.NoLimit, NoLimit: inv.NoLimit,
@ -983,6 +1032,7 @@ func (app *appContext) GetUsers(gc *gin.Context) {
ID: jfUser.ID, ID: jfUser.ID,
Name: jfUser.Name, Name: jfUser.Name,
Admin: jfUser.Policy.IsAdministrator, Admin: jfUser.Policy.IsAdministrator,
Disabled: jfUser.Policy.IsDisabled,
} }
user.LastActive = "n/a" user.LastActive = "n/a"
if !jfUser.LastActivityDate.IsZero() { if !jfUser.LastActivityDate.IsZero() {
@ -991,6 +1041,9 @@ func (app *appContext) GetUsers(gc *gin.Context) {
if email, ok := app.storage.emails[jfUser.ID]; ok { if email, ok := app.storage.emails[jfUser.ID]; ok {
user.Email = email.(string) user.Email = email.(string)
} }
if expiry, ok := app.storage.users[jfUser.ID]; ok {
user.Expiry = app.formatDatetime(expiry)
}
resp.UserList = append(resp.UserList, user) resp.UserList = append(resp.UserList, user)
} }
@ -1271,18 +1324,24 @@ func (app *appContext) ModifyConfig(gc *gin.Context) {
// @Summary Get a list of email names and IDs. // @Summary Get a list of email names and IDs.
// @Produce json // @Produce json
// @Param lang query string false "Language for email titles."
// @Success 200 {object} emailListDTO // @Success 200 {object} emailListDTO
// @Router /config/emails [get] // @Router /config/emails [get]
// @tags Configuration // @tags Configuration
func (app *appContext) GetEmails(gc *gin.Context) { func (app *appContext) GetEmails(gc *gin.Context) {
lang := gc.Query("lang")
if _, ok := app.storage.lang.Email[lang]; !ok {
lang = app.storage.lang.chosenEmailLang
}
gc.JSON(200, emailListDTO{ gc.JSON(200, emailListDTO{
"UserCreated": {Name: app.storage.lang.Email["en-us"].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled}, "UserCreated": {Name: app.storage.lang.Email[lang].UserCreated["name"], Enabled: app.storage.customEmails.UserCreated.Enabled},
"InviteExpiry": {Name: app.storage.lang.Email["en-us"].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled}, "InviteExpiry": {Name: app.storage.lang.Email[lang].InviteExpiry["name"], Enabled: app.storage.customEmails.InviteExpiry.Enabled},
"PasswordReset": {Name: app.storage.lang.Email["en-us"].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled}, "PasswordReset": {Name: app.storage.lang.Email[lang].PasswordReset["name"], Enabled: app.storage.customEmails.PasswordReset.Enabled},
"UserDeleted": {Name: app.storage.lang.Email["en-us"].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled}, "UserDeleted": {Name: app.storage.lang.Email[lang].UserDeleted["name"], Enabled: app.storage.customEmails.UserDeleted.Enabled},
"InviteEmail": {Name: app.storage.lang.Email["en-us"].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled}, "InviteEmail": {Name: app.storage.lang.Email[lang].InviteEmail["name"], Enabled: app.storage.customEmails.InviteEmail.Enabled},
"WelcomeEmail": {Name: app.storage.lang.Email["en-us"].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled}, "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.customEmails.WelcomeEmail.Enabled},
"EmailConfirmation": {Name: app.storage.lang.Email["en-us"].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.customEmails.EmailConfirmation.Enabled},
"UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.customEmails.UserExpired.Enabled},
}) })
} }
@ -1323,6 +1382,9 @@ func (app *appContext) SetEmail(gc *gin.Context) {
} else if id == "EmailConfirmation" { } else if id == "EmailConfirmation" {
app.storage.customEmails.EmailConfirmation.Content = req.Content app.storage.customEmails.EmailConfirmation.Content = req.Content
app.storage.customEmails.EmailConfirmation.Enabled = true app.storage.customEmails.EmailConfirmation.Enabled = true
} else if id == "UserExpired" {
app.storage.customEmails.UserExpired.Content = req.Content
app.storage.customEmails.UserExpired.Enabled = true
} else { } else {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -1364,6 +1426,8 @@ func (app *appContext) SetEmailState(gc *gin.Context) {
app.storage.customEmails.WelcomeEmail.Enabled = enabled app.storage.customEmails.WelcomeEmail.Enabled = enabled
} else if id == "EmailConfirmation" { } else if id == "EmailConfirmation" {
app.storage.customEmails.EmailConfirmation.Enabled = enabled app.storage.customEmails.EmailConfirmation.Enabled = enabled
} else if id == "UserExpired" {
app.storage.customEmails.UserExpired.Enabled = enabled
} else { } else {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -1383,6 +1447,7 @@ func (app *appContext) SetEmailState(gc *gin.Context) {
// @Router /config/emails/{id} [get] // @Router /config/emails/{id} [get]
// @tags Configuration // @tags Configuration
func (app *appContext) GetEmail(gc *gin.Context) { func (app *appContext) GetEmail(gc *gin.Context) {
lang := app.storage.lang.chosenEmailLang
id := gc.Param("id") id := gc.Param("id")
var content string var content string
var err error var err error
@ -1391,8 +1456,8 @@ func (app *appContext) GetEmail(gc *gin.Context) {
var values map[string]interface{} var values map[string]interface{}
var writeVars func(variables []string) var writeVars func(variables []string)
newEmail := false newEmail := false
username := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("username") username := app.storage.lang.Email[lang].Strings.get("username")
emailAddress := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("emailAddress") emailAddress := app.storage.lang.Email[lang].Strings.get("emailAddress")
if id == "UserCreated" { if id == "UserCreated" {
content = app.storage.customEmails.UserCreated.Content content = app.storage.customEmails.UserCreated.Content
if content == "" { if content == "" {
@ -1439,7 +1504,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
variables = app.storage.customEmails.UserDeleted.Variables variables = app.storage.customEmails.UserDeleted.Variables
} }
writeVars = func(variables []string) { app.storage.customEmails.UserDeleted.Variables = variables } writeVars = func(variables []string) { app.storage.customEmails.UserDeleted.Variables = variables }
values = app.email.deletedValues(app.storage.lang.Email[app.storage.lang.chosenEmailLang].UserDeleted.get("reason"), app, false) values = app.email.deletedValues(app.storage.lang.Email[lang].UserDeleted.get("reason"), app, false)
// app.storage.customEmails.UserDeleted = content // app.storage.customEmails.UserDeleted = content
} else if id == "InviteEmail" { } else if id == "InviteEmail" {
content = app.storage.customEmails.InviteEmail.Content content = app.storage.customEmails.InviteEmail.Content
@ -1477,6 +1542,17 @@ func (app *appContext) GetEmail(gc *gin.Context) {
writeVars = func(variables []string) { app.storage.customEmails.EmailConfirmation.Variables = variables } writeVars = func(variables []string) { app.storage.customEmails.EmailConfirmation.Variables = variables }
values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false) values = app.email.confirmationValues("xxxxxx", username, "xxxxxx", app, false)
// app.storage.customEmails.EmailConfirmation = content // app.storage.customEmails.EmailConfirmation = content
} else if id == "UserExpired" {
content = app.storage.customEmails.UserExpired.Content
if content == "" {
newEmail = true
msg, err = app.email.constructUserExpired(app, true)
content = msg.Text
} else {
variables = app.storage.customEmails.UserExpired.Variables
}
writeVars = func(variables []string) { app.storage.customEmails.UserExpired.Variables = variables }
values = app.email.userExpiredValues(app, false)
} else { } else {
respondBool(400, false, gc) respondBool(400, false, gc)
return return
@ -1505,6 +1581,9 @@ func (app *appContext) GetEmail(gc *gin.Context) {
} }
writeVars(variables) writeVars(variables)
} }
if variables == nil {
variables = []string{}
}
if app.storage.storeCustomEmails() != nil { if app.storage.storeCustomEmails() != nil {
respondBool(500, false, gc) respondBool(500, false, gc)
return return

View File

@ -22,6 +22,10 @@ func (app *appContext) GetPath(sect, key string) (fs.FS, string) {
return os.DirFS(dir), file return os.DirFS(dir), file
} }
func (app *appContext) MustSetValue(section, key, val string) {
app.config.Section(section).Key(key).SetValue(app.config.Section(section).Key(key).MustString(val))
}
func (app *appContext) loadConfig() error { func (app *appContext) loadConfig() error {
var err error var err error
app.config, err = ini.Load(app.configPath) app.config, err = ini.Load(app.configPath)
@ -29,42 +33,46 @@ func (app *appContext) loadConfig() error {
return err return err
} }
app.config.Section("jellyfin").Key("public_server").SetValue(app.config.Section("jellyfin").Key("public_server").MustString(app.config.Section("jellyfin").Key("server").String())) app.MustSetValue("jellyfin", "public_server", app.config.Section("jellyfin").Key("server").String())
for _, key := range app.config.Section("files").Keys() { for _, key := range app.config.Section("files").Keys() {
if name := key.Name(); name != "html_templates" && name != "lang_files" { if name := key.Name(); name != "html_templates" && name != "lang_files" {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
} }
} }
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails"} { for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
} }
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")
app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false))) app.config.Section("email").Key("no_username").SetValue(strconv.FormatBool(app.config.Section("email").Key("no_username").MustBool(false)))
app.config.Section("password_resets").Key("email_html").SetValue(app.config.Section("password_resets").Key("email_html").MustString("jfa-go:" + "email.html")) app.MustSetValue("password_resets", "email_html", "jfa-go:"+"email.html")
app.config.Section("password_resets").Key("email_text").SetValue(app.config.Section("password_resets").Key("email_text").MustString("jfa-go:" + "email.txt")) app.MustSetValue("password_resets", "email_text", "jfa-go:"+"email.txt")
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString("jfa-go:" + "invite-email.html")) app.MustSetValue("invite_emails", "email_html", "jfa-go:"+"invite-email.html")
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString("jfa-go:" + "invite-email.txt")) app.MustSetValue("invite_emails", "email_text", "jfa-go:"+"invite-email.txt")
app.config.Section("email_confirmation").Key("email_html").SetValue(app.config.Section("email_confirmation").Key("email_html").MustString("jfa-go:" + "confirmation.html")) app.MustSetValue("email_confirmation", "email_html", "jfa-go:"+"confirmation.html")
app.config.Section("email_confirmation").Key("email_text").SetValue(app.config.Section("email_confirmation").Key("email_text").MustString("jfa-go:" + "confirmation.txt")) app.MustSetValue("email_confirmation", "email_text", "jfa-go:"+"confirmation.txt")
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString("jfa-go:" + "expired.html")) app.MustSetValue("notifications", "expiry_html", "jfa-go:"+"expired.html")
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString("jfa-go:" + "expired.txt")) app.MustSetValue("notifications", "expiry_text", "jfa-go:"+"expired.txt")
app.config.Section("notifications").Key("created_html").SetValue(app.config.Section("notifications").Key("created_html").MustString("jfa-go:" + "created.html")) app.MustSetValue("notifications", "created_html", "jfa-go:"+"created.html")
app.config.Section("notifications").Key("created_text").SetValue(app.config.Section("notifications").Key("created_text").MustString("jfa-go:" + "created.txt")) app.MustSetValue("notifications", "created_text", "jfa-go:"+"created.txt")
app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString("jfa-go:" + "deleted.html")) app.MustSetValue("deletion", "email_html", "jfa-go:"+"deleted.html")
app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString("jfa-go:" + "deleted.txt")) app.MustSetValue("deletion", "email_text", "jfa-go:"+"deleted.txt")
app.config.Section("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString("jfa-go:" + "welcome.html")) app.MustSetValue("welcome_email", "email_html", "jfa-go:"+"welcome.html")
app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString("jfa-go:" + "welcome.txt")) app.MustSetValue("welcome_email", "email_text", "jfa-go:"+"welcome.txt")
app.config.Section("template_email").Key("email_html").SetValue(app.config.Section("template_email").Key("email_html").MustString("jfa-go:" + "template.html")) app.MustSetValue("template_email", "email_html", "jfa-go:"+"template.html")
app.config.Section("template_email").Key("email_text").SetValue(app.config.Section("template_email").Key("email_text").MustString("jfa-go:" + "template.txt")) app.MustSetValue("template_email", "email_text", "jfa-go:"+"template.txt")
app.MustSetValue("user_expiry", "behaviour", "disable_user")
app.MustSetValue("user_expiry", "email_html", "jfa-go:"+"user-expired.html")
app.MustSetValue("user_expiry", "email_text", "jfa-go:"+"user-expired.txt")
app.config.Section("jellyfin").Key("version").SetValue(version) app.config.Section("jellyfin").Key("version").SetValue(version)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device").SetValue("jfa-go")

View File

@ -765,6 +765,63 @@
} }
} }
}, },
"user_expiry": {
"order": [],
"meta": {
"name": "User Expiry",
"description": "When set on an invite, users will be deleted or disabled a specified amount of time after they create their account."
},
"settings": {
"behaviour": {
"name": "Behaviour",
"required": false,
"requires_restart": false,
"type": "select",
"options": [
["delete_user", "Delete user"],
["disable_user", "Disable user"]
],
"value": "disable_user",
"description": "Whether to delete or disable users on expiry."
},
"send_email": {
"name": "Send email",
"required": false,
"requires_restart": false,
"type": "bool",
"value": true,
"depends_true": "email|method",
"description": "Send an email when a user's account expires."
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Subject of user expiry emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "email|method",
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
}
},
"deletion": { "deletion": {
"order": [], "order": [],
"meta": { "meta": {
@ -822,6 +879,14 @@
"value": "", "value": "",
"description": "Location of stored email addresses (json)." "description": "Location of stored email addresses (json)."
}, },
"users": {
"name": "User storage",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores users temporarily when a user expiry is set."
},
"ombi_template": { "ombi_template": {
"name": "Ombi user template", "name": "Ombi user template",
"required": false, "required": false,

View File

@ -4,7 +4,7 @@ import "time"
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS // https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
type repeater struct { type inviteDaemon struct {
Stopped bool Stopped bool
ShutdownChannel chan string ShutdownChannel chan string
Interval time.Duration Interval time.Duration
@ -12,8 +12,8 @@ type repeater struct {
app *appContext app *appContext
} }
func newRepeater(interval time.Duration, app *appContext) *repeater { func newInviteDaemon(interval time.Duration, app *appContext) *inviteDaemon {
return &repeater{ return &inviteDaemon{
Stopped: false, Stopped: false,
ShutdownChannel: make(chan string), ShutdownChannel: make(chan string),
Interval: interval, Interval: interval,
@ -22,7 +22,7 @@ func newRepeater(interval time.Duration, app *appContext) *repeater {
} }
} }
func (rt *repeater) run() { func (rt *inviteDaemon) run() {
rt.app.info.Println("Invite daemon started") rt.app.info.Println("Invite daemon started")
for { for {
select { select {
@ -42,7 +42,7 @@ func (rt *repeater) run() {
} }
} }
func (rt *repeater) shutdown() { func (rt *inviteDaemon) shutdown() {
rt.Stopped = true rt.Stopped = true
rt.ShutdownChannel <- "Down" rt.ShutdownChannel <- "Down"
<-rt.ShutdownChannel <-rt.ShutdownChannel

View File

@ -569,6 +569,42 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext, noSub
return email, nil return email, nil
} }
func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[string]interface{} {
template := map[string]interface{}{
"yourAccountHasExpired": emailer.lang.UserExpired.get("yourAccountHasExpired"),
"contactTheAdmin": emailer.lang.UserExpired.get("contactTheAdmin"),
"message": "",
}
if !noSub {
template["message"] = app.config.Section("email").Key("message").String()
}
return template
}
func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Email, error) {
email := &Email{
Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")),
}
var err error
template := emailer.userExpiredValues(app, noSub)
if app.storage.customEmails.UserExpired.Enabled {
content := app.storage.customEmails.UserExpired.Content
for _, v := range app.storage.customEmails.UserExpired.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, "user_expiry", "email_", template)
}
if err != nil {
return nil, err
}
return email, nil
}
// calls the send method in the underlying emailClient. // calls the send method in the underlying emailClient.
func (emailer *Emailer) send(email *Email, address ...string) error { func (emailer *Emailer) send(email *Email, address ...string) error {
return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...) return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...)

View File

@ -9,6 +9,7 @@
window.ombiEnabled = {{ .ombiEnabled }}; window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }}; window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }}); window.langFile = JSON.parse({{ .language }});
window.language = "{{ .langName }}";
</script> </script>
{{ template "header.html" . }} {{ template "header.html" . }}
<title>Admin - jfa-go</title> <title>Admin - jfa-go</title>
@ -93,6 +94,35 @@
</div> </div>
</form> </form>
</div> </div>
<div id="modal-extend-expiry" class="modal">
<form class="modal-content card" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-half">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
<label>
<input type="submit" class="unfocused">
<span class="button ~critical !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
</div>
</form>
</div>
<div id="modal-announce" class="modal"> <div id="modal-announce" class="modal">
<form class="modal-content card" id="form-announce" href=""> <form class="modal-content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
@ -132,7 +162,7 @@
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row"> <div class="row">
<div class="col flex-col content mt-half"> <div class="col flex-col content mt-half">
<span class="label supra" for="editor-variables">{{ .strings.variables }}</span> <span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables"></div> <div id="editor-variables"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label> <label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral !normal mt-half monospace"></textarea> <textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral !normal mt-half monospace"></textarea>
@ -259,6 +289,17 @@
<span class="heading">{{ .strings.create }}</span> <span class="heading">{{ .strings.create }}</span>
<div class="row" id="create-inv"> <div class="row" id="create-inv">
<div class="card ~neutral !normal col"> <div class="card ~neutral !normal col">
<div class="flex-row mb-1">
<label class="flex-row-group mr-1">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral !high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="flex-row-group ml-1">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral !normal supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half"> <div class="select ~neutral !normal mb-1 mt-half">
<select id="create-days"> <select id="create-days">
@ -277,6 +318,34 @@
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div>
<div id="user-expiry" class="unfocused">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div class="mb-half">
<label for="create-user-expiry-enabled" class="button ~neutral !normal">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-half">{{ .strings.enabled }} </span>
</label>
</div>
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-days">
<option>0</option>
</select>
</div>
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-hours">
<option>0</option>
</select>
</div>
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral !normal mb-1 mt-half">
<select id="user-minutes">
<option>0</option>
</select>
</div>
</div>
<label class="label supra" for="create-label"> {{ .strings.label }}</label> <label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral !normal mb-1 mt-half"> <input type="text" id="create-label" class="input ~neutral !normal mb-1 mt-half">
</div> </div>
@ -313,10 +382,11 @@
<div class="card ~neutral !low accounts mb-1"> <div class="card ~neutral !low accounts mb-1">
<span class="heading">{{ .strings.accounts }}</span> <span class="heading">{{ .strings.accounts }}</span>
<div class="fr"> <div class="fr">
<span class="button ~neutral !normal" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span> <span class="button ~neutral !normal mb-half" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<span class="button ~info !normal" id="accounts-announce">{{ .strings.announce }}</span> <span class="button ~info !normal mb-half" id="accounts-announce">{{ .strings.announce }}</span>
<span class="button ~urge !normal" id="accounts-modify-user">{{ .strings.modifySettings }}</span> <span class="button ~urge !normal mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="button ~critical !normal" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span> <span class="button ~warning !normal mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="button ~critical !normal mb-half" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div> </div>
<div class="card ~neutral !normal accounts-header table-responsive mt-half"> <div class="card ~neutral !normal accounts-header table-responsive mt-half">
<table class="table"> <table class="table">
@ -325,6 +395,7 @@
<th><input type="checkbox" value="" id="accounts-select-all"></th> <th><input type="checkbox" value="" id="accounts-select-all"></th>
<th>{{ .strings.username }}</th> <th>{{ .strings.username }}</th>
<th>{{ .strings.emailAddress }}</th> <th>{{ .strings.emailAddress }}</th>
<th>{{ .strings.expiry }}</th>
<th>{{ .strings.lastActiveTime }}</th> <th>{{ .strings.lastActiveTime }}</th>
</tr> </tr>
</thead> </thead>

View File

@ -7,6 +7,11 @@
window.code = "{{ .code }}"; window.code = "{{ .code }}";
window.messages = JSON.parse({{ .notifications }}); window.messages = JSON.parse({{ .notifications }});
window.confirmation = {{ .confirmation }}; window.confirmation = {{ .confirmation }};
window.userExpiryEnabled = {{ .userExpiry }};
window.userExpiryDays = {{ .userExpiryDays }};
window.userExpiryHours = {{ .userExpiryHours }};
window.userExpiryMinutes = {{ .userExpiryMinutes }};
window.userExpiryMessage = {{ .userExpiryMessage }};
</script> </script>
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }} {{ end }}

View File

@ -37,6 +37,9 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{{ if .userExpiry }}
<aside class="col aside sm ~warning" id="user-expiry-message"></aside>
{{ end }}
<form class="card ~neutral !normal" id="form-create" href=""> <form class="card ~neutral !normal" id="form-create" href="">
<label class="label supra"> <label class="label supra">
{{ .strings.username }} {{ .strings.username }}
@ -44,13 +47,13 @@
</label> </label>
<label class="label supra" for="create-email">{{ .strings.emailAddress }}</label> <label class="label supra" for="create-email">{{ .strings.emailAddress }}</label>
<input type="email" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}"> <input type="email" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.emailAddress }}" id="create-email" aria-label="{{ .strings.emailAddress }}" value="{{ .email }}">
<label class="label supra" for="create-password">{{ .strings.password }}</label> <label class="label supra" for="create-password">{{ .strings.password }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}"> <input type="password" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-password" aria-label="{{ .strings.password }}">
<label class="label supra" for="create-reenter-password">{{ .strings.reEnterPassword }}</label> <label class="label supra" for="create-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral 1high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-reenter-password" aria-label="{{ .strings.reEnterPassword }}"> <input type="password" class="input ~neutral !high mt-half mb-1" placeholder="{{ .strings.password }}" id="create-reenter-password" aria-label="{{ .strings.reEnterPassword }}">
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.createAccountButton }}</span> <span class="button ~urge !normal full-width center supra submit">{{ .strings.createAccountButton }}</span>

View File

@ -83,6 +83,7 @@ type emailLang struct {
InviteEmail langSection `json:"inviteEmail"` InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"` WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"` EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"`
} }
type setupLangs map[string]setupLang type setupLangs map[string]setupLang

View File

@ -10,6 +10,7 @@
"inviteHours": "Hours", "inviteHours": "Hours",
"inviteMinutes": "Minutes", "inviteMinutes": "Minutes",
"inviteNumberOfUses": "Number of uses", "inviteNumberOfUses": "Number of uses",
"inviteDuration": "Invite Duration",
"warning": "Warning", "warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively", "inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to", "inviteSendToEmail": "Send to",
@ -20,9 +21,15 @@
"delete": "Delete", "delete": "Delete",
"name": "Name", "name": "Name",
"date": "Date", "date": "Date",
"enabled": "Enabled",
"disabled": "Disabled",
"admin": "Admin",
"lastActiveTime": "Last Active", "lastActiveTime": "Last Active",
"from": "From", "from": "From",
"user": "User", "user": "User",
"expiry": "Expiry",
"userExpiry": "User Expiry",
"userExpiryDescription": "A specified amount of time after each signup, jfa-go will delete/disable the account. You can change this behaviour in settings.",
"aboutProgram": "About", "aboutProgram": "About",
"version": "Version", "version": "Version",
"commitNoun": "Commit", "commitNoun": "Commit",
@ -37,6 +44,7 @@
"preview": "Preview", "preview": "Preview",
"reset": "Reset", "reset": "Reset",
"edit": "Edit", "edit": "Edit",
"extendExpiry": "Extend expiry",
"customizeEmails": "Customize Emails", "customizeEmails": "Customize Emails",
"customizeEmailsDescription": "If you don't want to use jfa-go's email templates, you can create your own using Markdown.", "customizeEmailsDescription": "If you don't want to use jfa-go's email templates, you can create your own using Markdown.",
"markdownSupported": "Markdown is supported.", "markdownSupported": "Markdown is supported.",
@ -137,6 +145,14 @@
"appliedSettings": { "appliedSettings": {
"singular": "Applied settings to {n} user.", "singular": "Applied settings to {n} user.",
"plural": "Applied settings to {n} users." "plural": "Applied settings to {n} users."
},
"extendExpiry": {
"singular": "Extend expiry for {n} user",
"plural": "Extend expiry for {n} users"
},
"extendedExpiry": {
"singular": "Extended expiry for {n} user.",
"plural": "Extended expiry for {n} users."
} }
} }
} }

View File

@ -55,5 +55,11 @@
"title": "Confirm your email - Jellyfin", "title": "Confirm your email - Jellyfin",
"clickBelow": "Click the link below to confirm your email address and start using Jellyfin.", "clickBelow": "Click the link below to confirm your email address and start using Jellyfin.",
"confirmEmail": "Confirm Email" "confirmEmail": "Confirm Email"
},
"userExpired": {
"name": "User expiry",
"title": "Your account has expired - Jellyfin",
"yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info."
} }
} }

View File

@ -16,7 +16,8 @@
"successHeader": "Success!", "successHeader": "Success!",
"successContinueButton": "Continue", "successContinueButton": "Continue",
"confirmationRequired": "Email confirmation required", "confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address." "confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}."
}, },
"notifications": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",

76
mail/user-expired.mjml Normal file
View File

@ -0,0 +1,76 @@
<mjml>
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
:root {
Color-scheme: light dark;
supported-color-schemes: light dark;
}
@media (prefers-color-scheme: light) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
@media (prefers-color-scheme: dark) {
Color-scheme: dark;
.body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsc] .body {
background: #242424 !important;
background-color: #242424 !important;
}
[data-ogsb] .body {
background: #242424 !important;
background-color: #242424 !important;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<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>{{ .yourAccountHasExpired }}</h3>
<p>{{ .contactTheAdmin }}</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>

5
mail/user-expired.txt Normal file
View File

@ -0,0 +1,5 @@
{{ .yourAccountHasExpired }}
{{ .contactTheAdmin }}
{{ .message }}

View File

@ -338,6 +338,8 @@ func start(asDaemon, firstCall bool) {
app.storage.loadConfiguration() app.storage.loadConfiguration()
app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String() app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
app.storage.loadDisplayprefs() app.storage.loadDisplayprefs()
app.storage.users_path = app.config.Section("files").Key("users").String()
app.storage.loadUsers()
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String() app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles() app.storage.loadProfiles()
@ -510,9 +512,12 @@ func start(asDaemon, firstCall bool) {
os.Exit(0) os.Exit(0)
} }
inviteDaemon := newRepeater(time.Duration(60*time.Second), app) inviteDaemon := newInviteDaemon(time.Duration(60*time.Second), app)
go inviteDaemon.run() go inviteDaemon.run()
userDaemon := newUserDaemon(time.Duration(60*time.Second), app)
go userDaemon.run()
if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer { if app.config.Section("password_resets").Key("enabled").MustBool(false) && serverType == mediabrowser.JellyfinServer {
go app.StartPWR() go app.StartPWR()
} }

View File

@ -33,6 +33,10 @@ type generateInviteDTO struct {
Days int `json:"days" example:"1"` // Number of days Days int `json:"days" example:"1"` // Number of days
Hours int `json:"hours" example:"2"` // Number of hours Hours int `json:"hours" example:"2"` // Number of hours
Minutes int `json:"minutes" example:"3"` // Number of minutes Minutes int `json:"minutes" example:"3"` // Number of minutes
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Email string `json:"email" example:"jeff@jellyf.in"` // Send invite to this address Email string `json:"email" example:"jeff@jellyf.in"` // Send invite to this address
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
@ -72,6 +76,10 @@ type inviteDTO struct {
Days int `json:"days" example:"1"` // Number of days till expiry Days int `json:"days" example:"1"` // Number of days till expiry
Hours int `json:"hours" example:"2"` // Number of hours till expiry Hours int `json:"hours" example:"2"` // Number of hours till expiry
Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry Minutes int `json:"minutes" example:"3"` // Number of minutes till expiry
UserExpiry bool `json:"user-expiry"` // Whether or not user expiry is enabled
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
Created string `json:"created" example:"01/01/20 12:00"` // Date of creation Created string `json:"created" example:"01/01/20 12:00"` // Date of creation
Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite
UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite UsedBy [][]string `json:"used-by,omitempty"` // Users who have used this invite
@ -106,6 +114,8 @@ type respUser struct {
Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available) Email string `json:"email,omitempty" example:"jeff@jellyf.in"` // Email address of user (if available)
LastActive string `json:"last_active"` // Time of last activity on Jellyfin LastActive string `json:"last_active"` // Time of last activity on Jellyfin
Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator Admin bool `json:"admin" example:"false"` // Whether or not the user is Administrator
Expiry string `json:"expiry" example:"01/02/21 12:00"` // Expiry time of user, if applicable.
Disabled bool `json:"disabled"` // Whether or not the user is disabled.
} }
type getUsersDTO struct { type getUsersDTO struct {
@ -197,3 +207,10 @@ type customEmailDTO struct {
HTML string `json:"html"` HTML string `json:"html"`
Plaintext string `json:"plaintext"` Plaintext string `json:"plaintext"`
} }
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
}

6
package-lock.json generated
View File

@ -236,9 +236,9 @@
"integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY=" "integrity": "sha1-vfpzUplmTfr9NFKe1PhSKidf6lY="
}, },
"esbuild": { "esbuild": {
"version": "0.8.50", "version": "0.8.53",
"resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.50.tgz", "resolved": "https://registry.npm.taobao.org/esbuild/download/esbuild-0.8.53.tgz",
"integrity": "sha1-6/JP3gza0aNpeJ3W/XqCCwoB5Gw=" "integrity": "sha1-tAi7DKGynasT2Lv31Z9Zr+Z3boY="
}, },
"escalade": { "escalade": {
"version": "3.1.1", "version": "3.1.1",

View File

@ -19,7 +19,7 @@
"dependencies": { "dependencies": {
"@ts-stack/markdown": "^1.3.0", "@ts-stack/markdown": "^1.3.0",
"a17t": "^0.4.0", "a17t": "^0.4.0",
"esbuild": "^0.8.50", "esbuild": "^0.8.53",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"mjml": "^4.8.0", "mjml": "^4.8.0",
"remixicon": "^2.5.0", "remixicon": "^2.5.0",

View File

@ -126,6 +126,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.DELETE(p+"/users", app.DeleteUsers) api.DELETE(p+"/users", app.DeleteUsers)
api.GET(p+"/users", app.GetUsers) api.GET(p+"/users", app.GetUsers)
api.POST(p+"/users", app.NewUserAdmin) api.POST(p+"/users", app.NewUserAdmin)
api.POST(p+"/users/extend", app.ExtendExpiry)
api.POST(p+"/invites", app.GenerateInvite) api.POST(p+"/invites", app.GenerateInvite)
api.GET(p+"/invites", app.GetInvites) api.GET(p+"/invites", app.GetInvites)
api.DELETE(p+"/invites", app.DeleteInvite) api.DELETE(p+"/invites", app.DeleteInvite)

View File

@ -15,7 +15,8 @@ import (
type Storage struct { type Storage struct {
timePattern string timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path string invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path string
users map[string]time.Time
invites Invites invites Invites
profiles map[string]Profile profiles map[string]Profile
defaultProfile string defaultProfile string
@ -34,6 +35,7 @@ type customEmails struct {
InviteEmail customEmail `json:"inviteEmail"` InviteEmail customEmail `json:"inviteEmail"`
WelcomeEmail customEmail `json:"welcomeEmail"` WelcomeEmail customEmail `json:"welcomeEmail"`
EmailConfirmation customEmail `json:"emailConfirmation"` EmailConfirmation customEmail `json:"emailConfirmation"`
UserExpired customEmail `json:"userExpired"`
} }
type customEmail struct { type customEmail struct {
@ -59,6 +61,10 @@ type Invite struct {
NoLimit bool `json:"no-limit"` NoLimit bool `json:"no-limit"`
RemainingUses int `json:"remaining-uses"` RemainingUses int `json:"remaining-uses"`
ValidTill time.Time `json:"valid_till"` ValidTill time.Time `json:"valid_till"`
UserExpiry bool `json:"user-duration"`
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
Email string `json:"email"` Email string `json:"email"`
UsedBy [][]string `json:"used-by"` UsedBy [][]string `json:"used-by"`
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
@ -403,6 +409,14 @@ func (st *Storage) storeInvites() error {
return storeJSON(st.invite_path, st.invites) return storeJSON(st.invite_path, st.invites)
} }
func (st *Storage) loadUsers() error {
return loadJSON(st.users_path, &st.users)
}
func (st *Storage) storeUsers() error {
return storeJSON(st.users_path, st.users)
}
func (st *Storage) loadEmails() error { func (st *Storage) loadEmails() error {
return loadJSON(st.emails_path, &st.emails) return loadJSON(st.emails_path, &st.emails)
} }

View File

@ -57,6 +57,8 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.editor = new Modal(document.getElementById("modal-editor")); window.modals.editor = new Modal(document.getElementById("modal-editor"));
window.modals.customizeEmails = new Modal(document.getElementById("modal-customize")); window.modals.customizeEmails = new Modal(document.getElementById("modal-customize"));
window.modals.extendExpiry = new Modal(document.getElementById("modal-extend-expiry"));
})(); })();
var inviteCreator = new createInvite(); var inviteCreator = new createInvite();

View File

@ -10,6 +10,11 @@ interface formWindow extends Window {
messages: { [key: string]: string }; messages: { [key: string]: string };
confirmation: boolean; confirmation: boolean;
confirmationModal: Modal confirmationModal: Modal
userExpiryEnabled: boolean;
userExpiryDays: number;
userExpiryHours: number;
userExpiryMinutes: number;
userExpiryMessage: string;
} }
interface pwValString { interface pwValString {
@ -34,6 +39,19 @@ if (window.confirmation) {
} }
declare var window: formWindow; declare var window: formWindow;
if (window.userExpiryEnabled) {
const messageEl = document.getElementById("user-expiry-message") as HTMLElement;
const calculateTime = () => {
let time = new Date()
time.setDate(time.getDate() + window.userExpiryDays);
time.setHours(time.getHours() + window.userExpiryHours);
time.setMinutes(time.getMinutes() + window.userExpiryMinutes);
messageEl.textContent = window.userExpiryMessage.replace("{date}", time.toDateString() + " " + time.toLocaleTimeString());
setTimeout(calculateTime, 1000);
};
calculateTime();
}
var defaultPwValStrings: pwValStrings = { var defaultPwValStrings: pwValStrings = {
length: { length: {
singular: "Must have at least {n} character", singular: "Must have at least {n} character",

View File

@ -6,6 +6,8 @@ interface User {
email: string | undefined; email: string | undefined;
last_active: string; last_active: string;
admin: boolean; admin: boolean;
disabled: boolean;
expiry: string;
} }
class user implements User { class user implements User {
@ -13,9 +15,11 @@ class user implements User {
private _check: HTMLInputElement; private _check: HTMLInputElement;
private _username: HTMLSpanElement; private _username: HTMLSpanElement;
private _admin: HTMLSpanElement; private _admin: HTMLSpanElement;
private _disabled: HTMLSpanElement;
private _email: HTMLInputElement; private _email: HTMLInputElement;
private _emailAddress: string; private _emailAddress: string;
private _emailEditButton: HTMLElement; private _emailEditButton: HTMLElement;
private _expiry: HTMLTableDataCellElement;
private _lastActive: HTMLTableDataCellElement; private _lastActive: HTMLTableDataCellElement;
id: string; id: string;
private _selected: boolean; private _selected: boolean;
@ -34,10 +38,21 @@ class user implements User {
set admin(state: boolean) { set admin(state: boolean) {
if (state) { if (state) {
this._admin.classList.add("chip", "~info", "ml-1"); this._admin.classList.add("chip", "~info", "ml-1");
this._admin.textContent = "Admin"; this._admin.textContent = window.lang.strings("admin");
} else { } else {
this._admin.classList.remove("chip", "~info", "ml-1"); this._admin.classList.remove("chip", "~info", "ml-1");
this._admin.textContent = "" this._admin.textContent = "";
}
}
get disabled(): boolean { return this._disabled.classList.contains("chip"); }
set disabled(state: boolean) {
if (state) {
this._disabled.classList.add("chip", "~warning", "ml-1");
this._disabled.textContent = window.lang.strings("disabled");
} else {
this._disabled.classList.remove("chip", "~warning", "ml-1");
this._disabled.textContent = "";
} }
} }
@ -52,6 +67,9 @@ class user implements User {
} }
} }
get expiry(): string { return this._expiry.textContent; }
set expiry(value: string) { this._expiry.textContent = value; }
get last_active(): string { return this._lastActive.textContent; } get last_active(): string { return this._lastActive.textContent; }
set last_active(value: string) { this._lastActive.textContent = value; } set last_active(value: string) { this._lastActive.textContent = value; }
@ -62,16 +80,19 @@ class user implements User {
this._row = document.createElement("tr") as HTMLTableRowElement; this._row = document.createElement("tr") as HTMLTableRowElement;
this._row.innerHTML = ` this._row.innerHTML = `
<td><input type="checkbox" value=""></td> <td><input type="checkbox" value=""></td>
<td><span class="accounts-username"></span> <span class="accounts-admin"></span></td> <td><span class="accounts-username"></span> <span class="accounts-admin"></span> <span class="accounts-disabled"></span></td>
<td><i class="icon ri-edit-line accounts-email-edit"></i><span class="accounts-email-container ml-half"></span></td> <td><i class="icon ri-edit-line accounts-email-edit"></i><span class="accounts-email-container ml-half"></span></td>
<td class="accounts-expiry"></td>
<td class="accounts-last-active"></td> <td class="accounts-last-active"></td>
`; `;
const emailEditor = `<input type="email" class="input ~neutral !normal stealth-input">`; const emailEditor = `<input type="email" class="input ~neutral !normal stealth-input">`;
this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement; this._check = this._row.querySelector("input[type=checkbox]") as HTMLInputElement;
this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement; this._username = this._row.querySelector(".accounts-username") as HTMLSpanElement;
this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement; this._admin = this._row.querySelector(".accounts-admin") as HTMLSpanElement;
this._disabled = this._row.querySelector(".accounts-disabled") as HTMLSpanElement;
this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement; this._email = this._row.querySelector(".accounts-email-container") as HTMLInputElement;
this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement; this._emailEditButton = this._row.querySelector(".accounts-email-edit") as HTMLElement;
this._expiry = this._row.querySelector(".accounts-expiry") as HTMLTableDataCellElement;
this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement; this._lastActive = this._row.querySelector(".accounts-last-active") as HTMLTableDataCellElement;
this._check.onchange = () => { this.selected = this._check.checked; } this._check.onchange = () => { this.selected = this._check.checked; }
@ -130,6 +151,8 @@ class user implements User {
this.email = user.email || ""; this.email = user.email || "";
this.last_active = user.last_active; this.last_active = user.last_active;
this.admin = user.admin; this.admin = user.admin;
this.disabled = user.disabled;
this.expiry = user.expiry;
} }
asElement = (): HTMLTableRowElement => { return this._row; } asElement = (): HTMLTableRowElement => { return this._row; }
@ -152,6 +175,7 @@ export class accountsList {
private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement; private _deleteUser = document.getElementById("accounts-delete-user") as HTMLSpanElement;
private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement; private _deleteNotify = document.getElementById("delete-user-notify") as HTMLInputElement;
private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement; private _deleteReason = document.getElementById("textarea-delete-user") as HTMLTextAreaElement;
private _extendExpiry = document.getElementById("accounts-extend-expiry") as HTMLSpanElement;
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement; private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement; private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement; private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;
@ -167,6 +191,24 @@ export class accountsList {
private _addUserEmail = this._addUserForm.querySelector("input[type=email]") as HTMLInputElement; private _addUserEmail = this._addUserForm.querySelector("input[type=email]") as HTMLInputElement;
private _addUserPassword = this._addUserForm.querySelector("input[type=password]") as HTMLInputElement; private _addUserPassword = this._addUserForm.querySelector("input[type=password]") as HTMLInputElement;
private _count = 30;
private _populateNumbers = () => {
const fieldIDs = ["days", "hours", "minutes"];
const prefixes = ["extend-expiry-"];
for (let i = 0; i < fieldIDs.length; i++) {
for (let j = 0; j < prefixes.length; j++) {
const field = document.getElementById(prefixes[j] + fieldIDs[i]);
field.textContent = '';
for (let n = 0; n <= this._count; n++) {
const opt = document.createElement("option") as HTMLOptionElement;
opt.textContent = ""+n;
opt.value = ""+n;
field.appendChild(opt);
}
}
}
}
get selectAll(): boolean { return this._selectAll.checked; } get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) { set selectAll(state: boolean) {
for (let id in this._users) { for (let id in this._users) {
@ -193,6 +235,7 @@ export class accountsList {
if (window.emailEnabled) { if (window.emailEnabled) {
this._announceButton.classList.add("unfocused"); this._announceButton.classList.add("unfocused");
} }
this._extendExpiry.classList.add("unfocused");
} else { } else {
if (this._checkCount == Object.keys(this._users).length) { if (this._checkCount == Object.keys(this._users).length) {
this._selectAll.checked = true; this._selectAll.checked = true;
@ -207,6 +250,18 @@ export class accountsList {
if (window.emailEnabled) { if (window.emailEnabled) {
this._announceButton.classList.remove("unfocused"); this._announceButton.classList.remove("unfocused");
} }
const list = this._collectUsers();
let anyNonExpiries = false;
for (let id of list) {
if (!this._users[id].expiry) {
anyNonExpiries = true;
this._extendExpiry.classList.add("unfocused");
break;
}
}
if (!anyNonExpiries) {
this._extendExpiry.classList.remove("unfocused");
}
} }
} }
@ -394,7 +449,39 @@ export class accountsList {
window.modals.modifyUser.show(); window.modals.modifyUser.show();
} }
extendExpiry = () => {
const list = this._collectUsers();
let applyList: string[] = [];
for (let id of list) {
if (this._users[id].expiry) {
applyList.push(id);
}
}
document.getElementById("header-extend-expiry").textContent = window.lang.quantity("extendExpiry", applyList.length);
const form = document.getElementById("form-extend-expiry") as HTMLFormElement;
form.onsubmit = (event: Event) => {
event.preventDefault();
let send = { "users": applyList }
for (let field of ["days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
}
_post("/users/extend", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200 && req.status != 204) {
window.notifications.customError("extendExpiryError", window.lang.notif("errorFailureCheckLogs"));
} else {
window.notifications.customSuccess("extendExpiry", window.lang.quantity("extendedExpiry", applyList.length));
}
window.modals.extendExpiry.close()
this.reload();
}
});
}
window.modals.extendExpiry.show();
}
constructor() { constructor() {
this._populateNumbers();
this._users = {}; this._users = {};
this._selectAll.checked = false; this._selectAll.checked = false;
this._selectAll.onchange = () => { this.selectAll = this._selectAll.checked }; this._selectAll.onchange = () => { this.selectAll = this._selectAll.checked };
@ -440,6 +527,9 @@ export class accountsList {
this._announceButton.onclick = this.announce; this._announceButton.onclick = this.announce;
this._announceButton.classList.add("unfocused"); this._announceButton.classList.add("unfocused");
this._extendExpiry.onclick = this.extendExpiry;
this._extendExpiry.classList.add("unfocused");
if (!window.usernameEnabled) { if (!window.usernameEnabled) {
this._addUserName.classList.add("unfocused"); this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail; this._addUserName = this._addUserEmail;

View File

@ -59,7 +59,20 @@ export class DOMInvite implements Invite {
get expiresIn(): string { return this._expiresIn } get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) { set expiresIn(expiry: string) {
this._expiresIn = expiry; this._expiresIn = expiry;
this._infoArea.querySelector("span.inv-expiry").textContent = expiry; this._infoArea.querySelector("span.inv-duration").textContent = expiry;
}
private _userExpiry: string;
get userExpiryTime(): string { return this._userExpiry; }
set userExpiryTime(d: string) {
const expiry = this._middle.querySelector("span.user-expiry") as HTMLSpanElement;
if (!d) {
expiry.textContent = "";
} else {
expiry.textContent = window.lang.strings("userExpiry");
}
this._userExpiry = d;
this._middle.querySelector("strong.user-expiry-time").textContent = d;
} }
private _remainingUses: string = "1"; private _remainingUses: string = "1";
@ -265,7 +278,7 @@ export class DOMInvite implements Invite {
<span class="inv-email-chip"><i></i></span> <span class="inv-email-chip"><i></i></span>
<span class="content sm"></span> <span class="content sm"></span>
</div> </div>
<span class="inv-expiry mr-1"></span> <span class="inv-duration mr-1"></span>
<span class="button ~critical !normal inv-delete">${window.lang.strings("delete")}</span> <span class="button ~critical !normal inv-delete">${window.lang.strings("delete")}</span>
<label> <label>
<i class="icon clickable ri-arrow-down-s-line not-rotated"></i> <i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
@ -331,6 +344,7 @@ export class DOMInvite implements Invite {
this._middle.innerHTML = ` this._middle.innerHTML = `
<p class="supra mb-1 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p> <p class="supra mb-1 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra mb-1">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p> <p class="supra mb-1">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra mb-1"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
`; `;
this._right = document.createElement('div') as HTMLDivElement; this._right = document.createElement('div') as HTMLDivElement;
@ -362,6 +376,7 @@ export class DOMInvite implements Invite {
if (invite.label) { if (invite.label) {
this.label = invite.label; this.label = invite.label;
} }
this.userExpiryTime = invite.userExpiryTime || "";
} }
asElement = (): HTMLDivElement => { return this._container; } asElement = (): HTMLDivElement => { return this._container; }
@ -462,13 +477,25 @@ function parseInvite(invite: { [f: string]: string | number | string[][] | boole
parsed.email = invite["email"] as string || ""; parsed.email = invite["email"] as string || "";
parsed.label = invite["label"] as string || ""; parsed.label = invite["label"] as string || "";
let time = ""; let time = "";
let userExpiryTime = "";
const fields = ["days", "hours", "minutes"]; const fields = ["days", "hours", "minutes"];
let prefixes = [""];
if (invite["user-expiry"] as boolean) { prefixes.push("user-"); }
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
if (invite[fields[i]] != 0) { for (let j = 0; j < prefixes.length; j++) {
time += `${invite[fields[i]]}${fields[i][0]} `; if (invite[prefixes[j]+fields[i]]) {
let text = `${invite[prefixes[j]+fields[i]]}${fields[i][0]} `;
if (prefixes[j] == "user-") {
userExpiryTime += text;
} else {
time += text;
}
}
} }
} }
parsed.expiresIn = window.lang.var("strings", "inviteExpiresInTime", time.slice(0, -1)); parsed.expiresIn = window.lang.var("strings", "inviteExpiresInTime", time.slice(0, -1));
parsed.userExpiry = invite["user-expiry"] as boolean;
parsed.userExpiryTime = userExpiryTime.slice(0, -1);
parsed.remainingUses = invite["no-limit"] ? "∞" : String(invite["remaining-uses"]) parsed.remainingUses = invite["no-limit"] ? "∞" : String(invite["remaining-uses"])
parsed.usedBy = invite["used-by"] as string[][] || []; parsed.usedBy = invite["used-by"] as string[][] || [];
parsed.created = invite["created"] as string || window.lang.strings("unknown"); parsed.created = invite["created"] as string || window.lang.strings("unknown");
@ -481,6 +508,7 @@ function parseInvite(invite: { [f: string]: string | number | string[][] | boole
export class createInvite { export class createInvite {
private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement; private _sendToEnabled = document.getElementById("create-send-to-enabled") as HTMLInputElement;
private _sendTo = document.getElementById("create-send-to") as HTMLInputElement; private _sendTo = document.getElementById("create-send-to") as HTMLInputElement;
private _userExpiryToggle = document.getElementById("create-user-expiry-enabled") as HTMLInputElement;
private _uses = document.getElementById('create-uses') as HTMLInputElement; private _uses = document.getElementById('create-uses') as HTMLInputElement;
private _infUses = document.getElementById("create-inf-uses") as HTMLInputElement; private _infUses = document.getElementById("create-inf-uses") as HTMLInputElement;
private _infUsesWarning = document.getElementById('create-inf-uses-warning') as HTMLParagraphElement; private _infUsesWarning = document.getElementById('create-inf-uses-warning') as HTMLParagraphElement;
@ -491,6 +519,14 @@ export class createInvite {
private _days = document.getElementById("create-days") as HTMLSelectElement; private _days = document.getElementById("create-days") as HTMLSelectElement;
private _hours = document.getElementById("create-hours") as HTMLSelectElement; private _hours = document.getElementById("create-hours") as HTMLSelectElement;
private _minutes = document.getElementById("create-minutes") as HTMLSelectElement; private _minutes = document.getElementById("create-minutes") as HTMLSelectElement;
private _userDays = document.getElementById("user-days") as HTMLSelectElement;
private _userHours = document.getElementById("user-hours") as HTMLSelectElement;
private _userMinutes = document.getElementById("user-minutes") as HTMLSelectElement;
private _invDurationButton = document.getElementById('radio-inv-duration') as HTMLInputElement;
private _userExpiryButton = document.getElementById('radio-user-expiry') as HTMLInputElement;
private _invDuration = document.getElementById('inv-duration');
private _userExpiry = document.getElementById('user-expiry');
// Broadcast when new invite created // Broadcast when new invite created
private _newInviteEvent = new CustomEvent("newInviteEvent"); private _newInviteEvent = new CustomEvent("newInviteEvent");
@ -498,9 +534,11 @@ export class createInvite {
private _count: Number = 30; private _count: Number = 30;
private _populateNumbers = () => { private _populateNumbers = () => {
const fieldIDs = ["create-days", "create-hours", "create-minutes"]; const fieldIDs = ["days", "hours", "minutes"];
const prefixes = ["create-", "user-"];
for (let i = 0; i < fieldIDs.length; i++) { for (let i = 0; i < fieldIDs.length; i++) {
const field = document.getElementById(fieldIDs[i]); for (let j = 0; j < prefixes.length; j++) {
const field = document.getElementById(prefixes[j] + fieldIDs[i]);
field.textContent = ''; field.textContent = '';
for (let n = 0; n <= this._count; n++) { for (let n = 0; n <= this._count; n++) {
const opt = document.createElement("option") as HTMLOptionElement; const opt = document.createElement("option") as HTMLOptionElement;
@ -510,6 +548,7 @@ export class createInvite {
} }
} }
} }
}
get label(): string { return this._label.value; } get label(): string { return this._label.value; }
set label(label: string) { this._label.value = label; } set label(label: string) { this._label.value = label; }
@ -580,6 +619,41 @@ export class createInvite {
this._minutes.value = ""+n; this._minutes.value = ""+n;
this._checkDurationValidity(); this._checkDurationValidity();
} }
get userExpiry(): boolean {
return this._userExpiryToggle.checked;
}
set userExpiry(enabled: boolean) {
this._userExpiryToggle.checked = enabled;
const parent = this._userExpiryToggle.parentElement;
if (enabled) {
parent.classList.add("~urge");
parent.classList.remove("~neutral");
} else {
parent.classList.add("~neutral");
parent.classList.remove("~urge");
}
this._userDays.disabled = !enabled;
this._userHours.disabled = !enabled;
this._userMinutes.disabled = !enabled;
}
get userDays(): number {
return +this._userDays.value;
}
set userDays(n: number) {
this._userDays.value = ""+n;
}
get userHours(): number {
return +this._userHours.value;
}
set userHours(n: number) {
this._userHours.value = ""+n;
}
get userMinutes(): number {
return +this._userMinutes.value;
}
set userMinutes(n: number) {
this._userMinutes.value = ""+n;
}
get sendTo(): string { return this._sendTo.value; } get sendTo(): string { return this._sendTo.value; }
set sendTo(address: string) { this._sendTo.value = address; } set sendTo(address: string) { this._sendTo.value = address; }
@ -613,10 +687,18 @@ export class createInvite {
create = () => { create = () => {
toggleLoader(this._createButton); toggleLoader(this._createButton);
let userExpiry = this.userExpiry;
if (this.userDays == 0 && this.userHours == 0 && this.userMinutes == 0) {
userExpiry = false;
}
let send = { let send = {
"days": this.days, "days": this.days,
"hours": this.hours, "hours": this.hours,
"minutes": this.minutes, "minutes": this.minutes,
"user-expiry": userExpiry,
"user-days": this.userDays,
"user-hours": this.userHours,
"user-minutes": this.userMinutes,
"multiple-uses": (this.uses > 1 || this.infiniteUses), "multiple-uses": (this.uses > 1 || this.infiniteUses),
"no-limit": this.infiniteUses, "no-limit": this.infiniteUses,
"remaining-uses": this.uses, "remaining-uses": this.uses,
@ -642,12 +724,42 @@ export class createInvite {
this._infUses.onchange = () => { this.infiniteUses = this.infiniteUses; }; this._infUses.onchange = () => { this.infiniteUses = this.infiniteUses; };
this.infiniteUses = false; this.infiniteUses = false;
this._sendToEnabled.onchange = () => { this.sendToEnabled = this.sendToEnabled; }; this._sendToEnabled.onchange = () => { this.sendToEnabled = this.sendToEnabled; };
this.userExpiry = false;
this._userExpiryToggle.onchange = () => { this.userExpiry = this._userExpiryToggle.checked; }
this._userDays.disabled = true;
this._userHours.disabled = true;
this._userMinutes.disabled = true;
this.sendToEnabled = false; this.sendToEnabled = false;
this._createButton.onclick = this.create; this._createButton.onclick = this.create;
this.sendTo = ""; this.sendTo = "";
this.uses = 1; this.uses = 1;
this.label = ""; this.label = "";
const checkDuration = () => {
const invSpan = this._invDurationButton.nextElementSibling as HTMLSpanElement;
const userSpan = this._userExpiryButton.nextElementSibling as HTMLSpanElement;
if (this._invDurationButton.checked) {
this._invDuration.classList.remove("unfocused");
this._userExpiry.classList.add("unfocused");
invSpan.classList.add("!high");
invSpan.classList.remove("!normal");
userSpan.classList.add("!normal");
userSpan.classList.remove("!high");
} else if (this._userExpiryButton.checked) {
this._userExpiry.classList.remove("unfocused");
this._invDuration.classList.add("unfocused");
invSpan.classList.add("!normal");
invSpan.classList.remove("!high");
userSpan.classList.add("!high");
userSpan.classList.remove("!normal");
}
};
this._userExpiryButton.checked = false;
this._invDurationButton.checked = true;
this._userExpiryButton.onchange = checkDuration;
this._invDurationButton.onchange = checkDuration;
this._days.onchange = this._checkDurationValidity; this._days.onchange = this._checkDurationValidity;
this._hours.onchange = this._checkDurationValidity; this._hours.onchange = this._checkDurationValidity;
this._minutes.onchange = this._checkDurationValidity; this._minutes.onchange = this._checkDurationValidity;

View File

@ -694,6 +694,7 @@ class EmailEditor {
private _form = document.getElementById("form-editor") as HTMLFormElement; private _form = document.getElementById("form-editor") as HTMLFormElement;
private _header = document.getElementById("header-editor") as HTMLSpanElement; private _header = document.getElementById("header-editor") as HTMLSpanElement;
private _variables = document.getElementById("editor-variables") as HTMLDivElement; private _variables = document.getElementById("editor-variables") as HTMLDivElement;
private _variablesLabel = document.getElementById("label-editor-variables") as HTMLElement;
private _textArea = document.getElementById("textarea-editor") as HTMLTextAreaElement; private _textArea = document.getElementById("textarea-editor") as HTMLTextAreaElement;
private _preview = document.getElementById("editor-preview") as HTMLDivElement; private _preview = document.getElementById("editor-preview") as HTMLDivElement;
private _previewContent: HTMLElement; private _previewContent: HTMLElement;
@ -745,6 +746,11 @@ class EmailEditor {
let ci = i % colors.length; let ci = i % colors.length;
innerHTML += '<span class="button ~' + colors[ci] +' !normal mb-1" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>' innerHTML += '<span class="button ~' + colors[ci] +' !normal mb-1" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>'
} }
if (this._templ.variables.length == 0) {
this._variablesLabel.classList.add("unfocused");
} else {
this._variablesLabel.classList.remove("unfocused");
}
this._variables.innerHTML = innerHTML this._variables.innerHTML = innerHTML
const buttons = this._variables.querySelectorAll("span.button") as NodeListOf<HTMLSpanElement>; const buttons = this._variables.querySelectorAll("span.button") as NodeListOf<HTMLSpanElement>;
for (let i = 0; i < this._templ.variables.length; i++) { for (let i = 0; i < this._templ.variables.length; i++) {
@ -761,11 +767,13 @@ class EmailEditor {
} }
loadPreview = () => { loadPreview = () => {
let content = this._textArea.value; let content = this._textArea.value;
if (this._templ.variables) {
for (let variable of this._templ.variables) { for (let variable of this._templ.variables) {
let value = this._templ.values[variable.slice(1, -1)]; let value = this._templ.values[variable.slice(1, -1)];
if (value === undefined) { value = variable; } if (value === undefined) { value = variable; }
content = content.replace(new RegExp(variable, "g"), value); content = content.replace(new RegExp(variable, "g"), value);
} }
}
if (this._templ.html == "") { if (this._templ.html == "") {
content = stripMarkdown(content); content = stripMarkdown(content);
this._previewContent.textContent = content; this._previewContent.textContent = content;
@ -785,7 +793,7 @@ class EmailEditor {
} }
showList = () => { showList = () => {
_get("/config/emails", null, (req: XMLHttpRequest) => { _get("/config/emails?lang=" + window.language, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
if (req.status != 200) { if (req.status != 200) {
window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs")); window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs"));

View File

@ -77,6 +77,7 @@ declare interface Modals {
announce: Modal; announce: Modal;
editor: Modal; editor: Modal;
customizeEmails: Modal; customizeEmails: Modal;
extendExpiry: Modal;
} }
interface Invite { interface Invite {
@ -90,6 +91,8 @@ interface Invite {
notifyCreation?: boolean; notifyCreation?: boolean;
profile?: string; profile?: string;
label?: string; label?: string;
userExpiry?: boolean;
userExpiryTime?: string;
} }
interface inviteList { interface inviteList {

113
userdaemon.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"time"
"github.com/hrfee/jfa-go/mediabrowser"
)
type userDaemon struct {
Stopped bool
ShutdownChannel chan string
Interval time.Duration
period time.Duration
app *appContext
}
func newUserDaemon(interval time.Duration, app *appContext) *userDaemon {
return &userDaemon{
Stopped: false,
ShutdownChannel: make(chan string),
Interval: interval,
period: interval,
app: app,
}
}
func (rt *userDaemon) run() {
rt.app.info.Println("User daemon started")
for {
select {
case <-rt.ShutdownChannel:
rt.ShutdownChannel <- "Down"
return
case <-time.After(rt.period):
break
}
started := time.Now()
rt.app.storage.loadInvites()
rt.app.checkUsers()
finished := time.Now()
duration := finished.Sub(started)
rt.period = rt.Interval - duration
}
}
func (app *appContext) checkUsers() {
if len(app.storage.users) == 0 {
return
}
app.info.Println("Daemon: Checking for user expiry")
users, status, err := app.jf.GetUsers(false)
if err != nil || status != 200 {
app.err.Printf("Failed to get users (%d): %s", status, err)
return
}
mode := "disable"
termPlural := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete"
termPlural = "Deleting"
}
email := false
if emailEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
email = true
}
for id, expiry := range app.storage.users {
if time.Now().After(expiry) {
found := false
var user mediabrowser.User
for _, u := range users {
if u.ID == id {
found = true
user = u
break
}
}
if !found {
app.info.Printf("Expired user already deleted, ignoring.")
continue
}
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
if mode == "delete" {
status, err = app.jf.DeleteUser(id)
} else if mode == "disable" {
user.Policy.IsDisabled = true
status, err = app.jf.SetPolicy(id, user.Policy)
}
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err)
continue
}
delete(app.storage.users, id)
if email {
address, ok := app.storage.emails[id]
if !ok {
continue
}
msg, err := app.email.constructUserExpired(app, false)
if err != nil {
app.err.Printf("Failed to construct expiry email for \"%s\": %s", user.Name, err)
} else if err := app.email.send(msg, address.(string)); err != nil {
app.err.Printf("Failed to send expiry email to \"%s\": %s", user.Name, err)
} else {
app.info.Printf("Sent expiry notification to \"%s\"", address.(string))
}
}
}
}
err = app.storage.storeUsers()
if err != nil {
app.err.Printf("Failed to store user duration: %s", err)
}
}

View File

@ -81,6 +81,7 @@ func (app *appContext) AdminPage(gc *gin.Context) {
"strings": app.storage.lang.Admin[lang].Strings, "strings": app.storage.lang.Admin[lang].Strings,
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings, "quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"language": app.storage.lang.Admin[lang].JSON, "language": app.storage.lang.Admin[lang].JSON,
"langName": lang,
}) })
} }
@ -182,6 +183,11 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
"notifications": app.storage.lang.Form[lang].notificationsJSON, "notifications": app.storage.lang.Form[lang].notificationsJSON,
"code": code, "code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false), "confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
"userExpiry": inv.UserExpiry,
"userExpiryDays": inv.UserDays,
"userExpiryHours": inv.UserHours,
"userExpiryMinutes": inv.UserMinutes,
"userExpiryMessage": app.storage.lang.Form[lang].Strings.get("yourAccountIsValidUntil"),
}) })
} }