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

Add optional email confirmation

If enabled, a confirmation email will be sent before the user can create
their account.
This commit is contained in:
Harvey Tindall 2021-01-30 19:19:12 +00:00
parent 736c39840f
commit ee026714d4
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
26 changed files with 411 additions and 194 deletions

View File

@ -1,38 +0,0 @@
This branch is for experimenting with [a17t](https://a17t.miles.land/) to replace bootstrap. Page structure is pretty much done (except setup.html), so i'm currently integrating this with the main app and existing web code.
#### todo
**general**
* [x] modal implementation
* [x] animations
* [x] utilities
* [x] CSS for light & dark
**admin**
* [x] invites tab
* [x] accounts tab
* [x] settings tab
* [x] modals
* [ ] integration with existing code
**invites**
* [x] page design
* [ ] integration with existing code
#### screenshots
##### dark
<p>
<img src="images/dark/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/dark/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/dark/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/dark/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/dark/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>
##### light
<p>
<img src="images/light/invites.png" alt="invites" style="width: 32%; height: auto;">
<img src="images/light/accounts.png" alt="accounts" style="width: 32%; height: auto;">
<img src="images/light/settings.png" alt="settings" style="width: 32%; height: auto;">
<img src="images/light/login-modal.png" alt="login modal" style="width: 32%; height: auto;">
<img src="images/light/modify-settings.png" alt="modify user settings modal" style="width: 32%; height: auto;">
</p>

193
api.go
View File

@ -3,10 +3,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/knz/strtime" "github.com/knz/strtime"
"github.com/lithammer/shortuuid/v3" "github.com/lithammer/shortuuid/v3"
@ -115,23 +118,27 @@ func (app *appContext) checkInvites() {
notify := data.Notify notify := data.Notify
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 { if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
app.debug.Printf("%s: Expiry notification", code) app.debug.Printf("%s: Expiry notification", code)
var wait sync.WaitGroup
for address, settings := range notify { for address, settings := range notify {
if !settings["notify-expiry"] { if !settings["notify-expiry"] {
continue continue
} }
go func() { wait.Add(1)
go func(addr string) {
defer wait.Done()
msg, err := app.email.constructExpiry(code, data, app) msg, err := app.email.constructExpiry(code, data, app)
if err != nil { if err != nil {
app.err.Printf("%s: Failed to construct expiry notification", code) app.err.Printf("%s: Failed to construct expiry notification", code)
app.debug.Printf("Error: %s", err) app.debug.Printf("Error: %s", err)
} else if err := app.email.send(address, msg); err != nil { } else if err := app.email.send(addr, msg); err != nil {
app.err.Printf("%s: Failed to send expiry notification", code) app.err.Printf("%s: Failed to send expiry notification", code)
app.debug.Printf("Error: %s", err) app.debug.Printf("Error: %s", err)
} else { } else {
app.info.Printf("Sent expiry notification to %s", address) app.info.Printf("Sent expiry notification to %s", addr)
} }
}() }(address)
} }
wait.Wait()
} }
changed = true changed = true
delete(app.storage.invites, code) delete(app.storage.invites, code)
@ -184,7 +191,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
delete(app.storage.invites, code) delete(app.storage.invites, code)
} else if newInv.RemainingUses != 0 { } else if newInv.RemainingUses != 0 {
// 0 means infinite i guess? // 0 means infinite i guess?
newInv.RemainingUses -= 1 newInv.RemainingUses--
} }
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)}) newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)})
if !del { if !del {
@ -312,47 +319,67 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
respondUser(200, true, true, "", gc) respondUser(200, true, true, "", gc)
} }
// @Summary Creates a new Jellyfin user via invite code type errorFunc func(gc *gin.Context)
// @Produce json
// @Param newUserDTO body newUserDTO true "New user request object" func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
// @Success 200 {object} PasswordValidation
// @Failure 400 {object} PasswordValidation
// @Router /newUser [post]
// @tags Users
func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
app.debug.Printf("%s: New user attempt", req.Code)
if !app.checkInvite(req.Code, false, "") {
app.info.Printf("%s New user failed: invalid code", req.Code)
respond(401, "errorInvalidCode", gc)
return
}
validation := app.validator.validate(req.Password)
valid := true
for _, val := range validation {
if !val {
valid = false
}
}
if !valid {
// 200 bcs idk what i did in js
app.info.Printf("%s New user failed: Invalid password", req.Code)
gc.JSON(200, validation)
gc.Abort()
return
}
existingUser, _, _ := app.jf.UserByName(req.Username, false) existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser != nil { if existingUser != nil {
msg := fmt.Sprintf("User %s", req.Username) f = func(gc *gin.Context) {
app.info.Printf("%s New user failed: %s", req.Code, msg) msg := fmt.Sprintf("User %s already exists", req.Username)
respond(401, "errorUserExists", gc) app.info.Printf("%s: New user failed: %s", req.Code, msg)
respond(401, "errorUserExists", gc)
}
success = false
return return
} }
if app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed {
claims := jwt.MapClaims{
"valid": true,
"invite": req.Code,
"email": req.Email,
"username": req.Username,
"password": req.Password,
"exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10),
"type": "confirmation",
}
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET")))
if err != nil {
f = func(gc *gin.Context) {
app.info.Printf("Failed to generate confirmation token: %v", err)
respond(500, "errorUnknown", gc)
}
success = false
return
}
inv := app.storage.invites[req.Code]
inv.Keys = append(inv.Keys, key)
app.storage.invites[req.Code] = inv
app.storage.storeInvites()
f = func(gc *gin.Context) {
app.debug.Printf("%s: Email confirmation required", req.Code)
respond(401, "confirmEmail", gc)
msg, err := app.email.constructConfirmation(req.Code, req.Username, key, app)
if err != nil {
app.err.Printf("%s: Failed to construct confirmation email", req.Code)
app.debug.Printf("%s: Error: %s", req.Code, err)
} else if err := app.email.send(req.Email, msg); err != nil {
app.err.Printf("%s: Failed to send user confirmation email: %s", req.Code, err)
} else {
app.info.Printf("%s: Sent user confirmation email to %s", req.Code, req.Email)
}
}
success = false
return
}
user, status, err := app.jf.NewUser(req.Username, req.Password) user, status, err := app.jf.NewUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status) f = func(gc *gin.Context) {
respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc) app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Code, status)
respond(401, app.storage.lang.Admin[app.storage.lang.chosenAdminLang].Notifications.get("errorUnknown"), gc)
}
success = false
return return
} }
app.storage.loadProfiles() app.storage.loadProfiles()
@ -434,6 +461,44 @@ func (app *appContext) NewUser(gc *gin.Context) {
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)
} }
} }
success = true
return
}
// @Summary Creates a new Jellyfin user via invite code
// @Produce json
// @Param newUserDTO body newUserDTO true "New user request object"
// @Success 200 {object} PasswordValidation
// @Failure 400 {object} PasswordValidation
// @Router /newUser [post]
// @tags Users
func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
app.debug.Printf("%s: New user attempt", req.Code)
if !app.checkInvite(req.Code, false, "") {
app.info.Printf("%s New user failed: invalid code", req.Code)
respond(401, "errorInvalidCode", gc)
return
}
validation := app.validator.validate(req.Password)
valid := true
for _, val := range validation {
if !val {
valid = false
}
}
if !valid {
// 200 bcs idk what i did in js
app.info.Printf("%s New user failed: Invalid password", req.Code)
gc.JSON(200, validation)
return
}
f, success := app.newUser(req, false)
if !success {
f(gc)
return
}
code := 200 code := 200
for _, val := range validation { for _, val := range validation {
if !val { if !val {
@ -1317,54 +1382,6 @@ func (app *appContext) ServeLang(gc *gin.Context) {
respondBool(400, false, gc) respondBool(400, false, gc)
} }
// func Restart() error {
// defer func() {
// if r := recover(); r != nil {
// os.Exit(0)
// }
// }()
// cwd, err := os.Getwd()
// if err != nil {
// return err
// }
// args := os.Args
// // for _, key := range args {
// // fmt.Println(key)
// // }
// cmd := exec.Command(args[0], args[1:]...)
// cmd.Stdout = os.Stdout
// cmd.Stderr = os.Stderr
// cmd.Dir = cwd
// err = cmd.Start()
// if err != nil {
// return err
// }
// // cmd.Process.Release()
// panic(fmt.Errorf("restarting"))
// }
// func (app *appContext) Restart() error {
// defer func() {
// if r := recover(); r != nil {
// signal.Notify(app.quit, os.Interrupt)
// <-app.quit
// }
// }()
// args := os.Args
// // After a single restart, args[0] gets messed up and isnt the real executable.
// // JFA_DEEP tells the new process its a child, and JFA_EXEC is the real executable
// if os.Getenv("JFA_DEEP") == "" {
// os.Setenv("JFA_DEEP", "1")
// os.Setenv("JFA_EXEC", args[0])
// }
// env := os.Environ()
// err := syscall.Exec(os.Getenv("JFA_EXEC"), []string{""}, env)
// if err != nil {
// return err
// }
// panic(fmt.Errorf("restarting"))
// }
// no need to syscall.exec anymore! // no need to syscall.exec anymore!
func (app *appContext) Restart() error { func (app *appContext) Restart() error {
RESTART <- true RESTART <- true

View File

@ -35,6 +35,10 @@ func (app *appContext) loadConfig() error {
app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.localPath, "invite-email.html"))) app.config.Section("invite_emails").Key("email_html").SetValue(app.config.Section("invite_emails").Key("email_html").MustString(filepath.Join(app.localPath, "invite-email.html")))
app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.localPath, "invite-email.txt"))) app.config.Section("invite_emails").Key("email_text").SetValue(app.config.Section("invite_emails").Key("email_text").MustString(filepath.Join(app.localPath, "invite-email.txt")))
app.config.Section("email_confirmation").Key("email_html").SetValue(app.config.Section("email_confirmation").Key("email_html").MustString(filepath.Join(app.localPath, "confirmation.html")))
fmt.Println(app.config.Section("email_confirmation").Key("email_html").String())
app.config.Section("email_confirmation").Key("email_text").SetValue(app.config.Section("email_confirmation").Key("email_text").MustString(filepath.Join(app.localPath, "confirmation.txt")))
app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.localPath, "expired.html"))) app.config.Section("notifications").Key("expiry_html").SetValue(app.config.Section("notifications").Key("expiry_html").MustString(filepath.Join(app.localPath, "expired.html")))
app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt"))) app.config.Section("notifications").Key("expiry_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt")))

View File

@ -708,6 +708,46 @@
} }
} }
}, },
"email_confirmation": {
"order": [],
"meta": {
"name": "Email confirmation",
"description": "If enabled, a user will be sent an email confirmation link to ensure their password is right before they can make an account."
},
"settings": {
"enabled": {
"name": "Enabled",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false
},
"subject": {
"name": "Email subject",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Subject of email confirmation emails."
},
"email_html": {
"name": "Custom email (HTML)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email html"
},
"email_text": {
"name": "Custom email (plaintext)",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Path to custom email in plain text"
}
}
},
"deletion": { "deletion": {
"order": [], "order": [],
"meta": { "meta": {

View File

@ -153,9 +153,44 @@ func (emailer *Emailer) NewSMTP(server string, port int, username, password stri
} }
} }
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext) (*Email, error) {
email := &Email{
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
}
message := app.config.Section("email").Key("message").String()
inviteLink := app.config.Section("invite_emails").Key("url_base").String()
inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key)
for _, key := range []string{"html", "text"} {
fpath := app.config.Section("email_confirmation").Key("email_" + key).String()
tpl, err := template.ParseFiles(fpath)
if err != nil {
return nil, err
}
var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{
"helloUser": emailer.lang.Strings.format("helloUser", username),
"clickBelow": emailer.lang.EmailConfirmation.get("clickBelow"),
"ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"urlVal": inviteLink,
"confirmEmail": emailer.lang.EmailConfirmation.get("confirmEmail"),
"message": message,
})
if err != nil {
return nil, err
}
if key == "html" {
email.html = tplData.String()
} else {
email.text = tplData.String()
}
}
return email, nil
}
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) { func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) {
email := &Email{ email := &Email{
subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
} }
expiry := invite.ValidTill expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
@ -240,8 +275,8 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
"aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""), "aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""),
"name": emailer.lang.UserCreated.get("name"), "name": emailer.lang.Strings.get("name"),
"address": emailer.lang.UserCreated.get("emailAddress"), "address": emailer.lang.Strings.get("emailAddress"),
"time": emailer.lang.UserCreated.get("time"), "time": emailer.lang.UserCreated.get("time"),
"nameVal": username, "nameVal": username,
"addressVal": tplAddress, "addressVal": tplAddress,
@ -274,11 +309,11 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Ema
} }
var tplData bytes.Buffer var tplData bytes.Buffer
err = tpl.Execute(&tplData, map[string]string{ err = tpl.Execute(&tplData, map[string]string{
"helloUser": emailer.lang.PasswordReset.format("helloUser", pwr.Username), "helloUser": emailer.lang.Strings.format("helloUser", pwr.Username),
"someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"),
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"), "ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
"codeExpiry": emailer.lang.PasswordReset.format("codeExpiry", d, t, expiresIn), "codeExpiry": emailer.lang.PasswordReset.format("codeExpiry", d, t, expiresIn),
"ifItWasNotYou": emailer.lang.PasswordReset.get("ifItWasNotYou"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"),
"pin": emailer.lang.PasswordReset.get("pin"), "pin": emailer.lang.PasswordReset.get("pin"),
"pinVal": pwr.Pin, "pinVal": pwr.Pin,
"message": message, "message": message,
@ -339,7 +374,7 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Ema
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"), "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
"jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"), "jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"),
"jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(), "jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(),
"username": emailer.lang.WelcomeEmail.get("username"), "username": emailer.lang.Strings.get("username"),
"usernameVal": username, "usernameVal": username,
"message": app.config.Section("email").Key("message").String(), "message": app.config.Section("email").Key("message").String(),
}) })

18
html/create-success.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="{{ .cssClass }}">
<head>
<link rel="stylesheet" type="text/css" href="css/bundle.css">
{{ template "header.html" . }}
<title>{{ .strings.successHeader }} - jfa-go</title>
</head>
<body class="section">
<div class="page-container">
<div class="card ~neutral !normal mb-1">
<span class="heading mb-1">{{ .strings.successHeader }}</span>
<p class="content mb-1">{{ .successMessage }}</p>
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.successContinueButton }}</a>
</div>
<i class="content">{{ .contactMessage }}</i>
</div>
</body>
</html>

View File

@ -6,6 +6,7 @@
window.URLBase = "{{ .urlBase }}"; window.URLBase = "{{ .urlBase }}";
window.code = "{{ .code }}"; window.code = "{{ .code }}";
window.messages = JSON.parse({{ .notifications }}); window.messages = JSON.parse({{ .notifications }});
window.confirmation = {{ .confirmation }};
</script> </script>
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }} {{ end }}

View File

@ -13,6 +13,12 @@
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.successContinueButton }}</a> <a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.successContinueButton }}</a>
</div> </div>
</div> </div>
<div id="modal-confirmation" class="modal">
<div class="modal-content card">
<span class="heading mb-1">{{ .strings.confirmationRequired }}</span>
<p class="content mb-1">{{ .strings.confirmationRequiredMessage }}</p>
</div>
</div>
<span class="dropdown" tabindex="0" id="lang-dropdown"> <span class="dropdown" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>

16
lang.go
View File

@ -77,13 +77,15 @@ func (ls *emailLangs) getOptions(chosen string) (string, []string) {
} }
type emailLang struct { type emailLang struct {
Meta langMeta `json:"meta"` Meta langMeta `json:"meta"`
UserCreated langSection `json:"userCreated"` Strings langSection `json:"strings"`
InviteExpiry langSection `json:"inviteExpiry"` UserCreated langSection `json:"userCreated"`
PasswordReset langSection `json:"passwordReset"` InviteExpiry langSection `json:"inviteExpiry"`
UserDeleted langSection `json:"userDeleted"` PasswordReset langSection `json:"passwordReset"`
InviteEmail langSection `json:"inviteEmail"` UserDeleted langSection `json:"userDeleted"`
WelcomeEmail langSection `json:"welcomeEmail"` InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"`
} }
type setupLangs map[string]setupLang type setupLangs map[string]setupLang

View File

@ -4,6 +4,7 @@
}, },
"strings": { "strings": {
"username": "Benutzername", "username": "Benutzername",
"name": "Name",
"password": "Passwort", "password": "Passwort",
"emailAddress": "E-Mail-Adresse", "emailAddress": "E-Mail-Adresse",
"submit": "Absenden", "submit": "Absenden",

View File

@ -6,6 +6,7 @@
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"emailAddress": "Email Address", "emailAddress": "Email Address",
"name": "Name",
"submit": "Submit", "submit": "Submit",
"success": "Success", "success": "Success",
"error": "Error", "error": "Error",

View File

@ -5,6 +5,7 @@
}, },
"strings": { "strings": {
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"name": "Nom",
"password": "Mot de passe", "password": "Mot de passe",
"emailAddress": "Addresse Email", "emailAddress": "Addresse Email",
"submit": "Soumettre", "submit": "Soumettre",

View File

@ -4,6 +4,7 @@
}, },
"strings": { "strings": {
"username": "Gebruikersnaam", "username": "Gebruikersnaam",
"name": "Naam",
"password": "Wachtwoord", "password": "Wachtwoord",
"emailAddress": "E-mailadres", "emailAddress": "E-mailadres",
"submit": "Verstuur", "submit": "Verstuur",

View File

@ -4,6 +4,7 @@
}, },
"strings": { "strings": {
"username": "Nome do Usuário", "username": "Nome do Usuário",
"name": "Nome",
"password": "Senha", "password": "Senha",
"emailAddress": "Endereço de Email", "emailAddress": "Endereço de Email",
"submit": "Enviar", "submit": "Enviar",

View File

@ -2,11 +2,13 @@
"meta": { "meta": {
"name": "Deutsch (DE)" "name": "Deutsch (DE)"
}, },
"strings": {
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
"helloUser": "Hallo {n},"
},
"userCreated": { "userCreated": {
"title": "Mitteilung: Benutzer erstellt", "title": "Mitteilung: Benutzer erstellt",
"aUserWasCreated": "Ein Benutzer wurde unter Verwendung des Codes {n] erstellt.", "aUserWasCreated": "Ein Benutzer wurde unter Verwendung des Codes {n] erstellt.",
"name": "Name",
"emailAddress": "Adresse",
"time": "Zeit", "time": "Zeit",
"notificationNotice": "Hinweis: Benachrichtigungs-E-Mails können auf dem Administrator-Dashboard umgeschalten werden." "notificationNotice": "Hinweis: Benachrichtigungs-E-Mails können auf dem Administrator-Dashboard umgeschalten werden."
}, },
@ -18,11 +20,9 @@
}, },
"passwordReset": { "passwordReset": {
"title": "Passwortzurücksetzung angefordert - Jellyfin", "title": "Passwortzurücksetzung angefordert - Jellyfin",
"helloUser": "Hallo {n},",
"someoneHasRequestedReset": "Jemand hat vor kurzem eine Passwortzurücksetzung auf Jellyfin angefordert.", "someoneHasRequestedReset": "Jemand hat vor kurzem eine Passwortzurücksetzung auf Jellyfin angefordert.",
"ifItWasYou": "Wenn du das warst, gib die PIN unten in die Eingabeaufforderung ein.", "ifItWasYou": "Wenn du das warst, gib die PIN unten in die Eingabeaufforderung ein.",
"codeExpiry": "Der Code wird am {n}, um [n} UTC ablaufen, was in {n} ist.", "codeExpiry": "Der Code wird am {n}, um [n} UTC ablaufen, was in {n} ist.",
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
"pin": "PIN" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
@ -42,7 +42,6 @@
"title": "Wilkommen bei Jellyfin", "title": "Wilkommen bei Jellyfin",
"welcome": "Willkommen bei Jellyfin!", "welcome": "Willkommen bei Jellyfin!",
"youCanLoginWith": "Du kannst dich mit den mit den untenstehenden Zugangsdaten anmelden", "youCanLoginWith": "Du kannst dich mit den mit den untenstehenden Zugangsdaten anmelden",
"jellyfinURL": "URL", "jellyfinURL": "URL"
"username": "Benutzername"
} }
} }

View File

@ -2,11 +2,13 @@
"meta": { "meta": {
"name": "English (US)" "name": "English (US)"
}, },
"strings": {
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
"helloUser": "Hi {n},"
},
"userCreated": { "userCreated": {
"title": "Notice: User created", "title": "Notice: User created",
"aUserWasCreated": "A user was created using code {n}.", "aUserWasCreated": "A user was created using code {n}.",
"name": "Name",
"emailAddress": "Address",
"time": "Time", "time": "Time",
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard." "notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
}, },
@ -18,11 +20,9 @@
}, },
"passwordReset": { "passwordReset": {
"title": "Password reset requested - Jellyfin", "title": "Password reset requested - Jellyfin",
"helloUser": "Hi {n},",
"someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.", "someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.",
"ifItWasYou": "If this was you, enter the pin below into the prompt.", "ifItWasYou": "If this was you, enter the pin below into the prompt.",
"codeExpiry": "The code will expire on {n}, at {n} UTC, which is in {n}.", "codeExpiry": "The code will expire on {n}, at {n} UTC, which is in {n}.",
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
"pin": "PIN" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
@ -42,7 +42,11 @@
"title": "Welcome to Jellyfin", "title": "Welcome to Jellyfin",
"welcome": "Welcome to Jellyfin!", "welcome": "Welcome to Jellyfin!",
"youCanLoginWith": "You can login with the details below", "youCanLoginWith": "You can login with the details below",
"jellyfinURL": "URL", "jellyfinURL": "URL"
"username": "Username" },
"emailConfirmation": {
"title": "Confirm your email - Jellyfin",
"clickBelow": "Click the link below to confirm your email address and start using Jellyfin.",
"confirmEmail": "Confirm Email"
} }
} }

View File

@ -3,11 +3,13 @@
"name": "Français (FR)", "name": "Français (FR)",
"author": "https://github.com/Cornichon420" "author": "https://github.com/Cornichon420"
}, },
"strings": {
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
"helloUser": "Salut {n},"
},
"userCreated": { "userCreated": {
"title": "Notification : Utilisateur créé", "title": "Notification : Utilisateur créé",
"aUserWasCreated": "Un utilisateur a été créé avec ce code {n}.", "aUserWasCreated": "Un utilisateur a été créé avec ce code {n}.",
"name": "Nom",
"emailAddress": "Adresse",
"time": "Date", "time": "Date",
"notificationNotice": "Note : Les emails de notification peuvent être activés sur le tableau de bord administrateur." "notificationNotice": "Note : Les emails de notification peuvent être activés sur le tableau de bord administrateur."
}, },
@ -19,11 +21,9 @@
}, },
"passwordReset": { "passwordReset": {
"title": "Réinitialisation de mot du passe demandée - Jellyfin", "title": "Réinitialisation de mot du passe demandée - Jellyfin",
"helloUser": "Salut {n},",
"someoneHasRequestedReset": "Quelqu'un vient de demander une réinitialisation du mot de passe via Jellyfin.", "someoneHasRequestedReset": "Quelqu'un vient de demander une réinitialisation du mot de passe via Jellyfin.",
"ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.", "ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.",
"codeExpiry": "Ce code expirera le {n}, à {n} UTC, soit dans {n}.", "codeExpiry": "Ce code expirera le {n}, à {n} UTC, soit dans {n}.",
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
"pin": "PIN" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
@ -43,7 +43,6 @@
"youCanLoginWith": "Tu peux te connecter avec les informations ci-dessous", "youCanLoginWith": "Tu peux te connecter avec les informations ci-dessous",
"title": "Bienvenue sur Jellyfin", "title": "Bienvenue sur Jellyfin",
"welcome": "Bienvenue sur Jellyfin !", "welcome": "Bienvenue sur Jellyfin !",
"jellyfinURL": "URL", "jellyfinURL": "URL"
"username": "Nom d'utilisateur"
} }
} }

View File

@ -2,11 +2,13 @@
"meta": { "meta": {
"name": "Nederlands (NL)" "name": "Nederlands (NL)"
}, },
"strings": {
"ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.",
"helloUser": "Hoi {n},"
},
"userCreated": { "userCreated": {
"title": "Melding: Gebruiker aangemaakt", "title": "Melding: Gebruiker aangemaakt",
"aUserWasCreated": "Er is een gebruiker aangemaakt door gebruik te maken van code {n}.", "aUserWasCreated": "Er is een gebruiker aangemaakt door gebruik te maken van code {n}.",
"name": "Naam",
"emailAddress": "Adres",
"time": "Tijdstip", "time": "Tijdstip",
"notificationNotice": "Opmerking: Meldingsemails kunnen worden aan- of uitgezet via het admin dashboard." "notificationNotice": "Opmerking: Meldingsemails kunnen worden aan- of uitgezet via het admin dashboard."
}, },
@ -18,11 +20,9 @@
}, },
"passwordReset": { "passwordReset": {
"title": "Wachtwoordreset aangevraagd - Jellyfin", "title": "Wachtwoordreset aangevraagd - Jellyfin",
"helloUser": "Hoi {n},",
"someoneHasRequestedReset": "Iemand heeft recentelijk een wachtwoordreset aangevraagd in Jellyfin.", "someoneHasRequestedReset": "Iemand heeft recentelijk een wachtwoordreset aangevraagd in Jellyfin.",
"ifItWasYou": "Als jij dit was, voor dan onderstaande PIN in.", "ifItWasYou": "Als jij dit was, voor dan onderstaande PIN in.",
"codeExpiry": "De code verloopt op {n}, op {n} UTC, dat is over {n}.", "codeExpiry": "De code verloopt op {n}, op {n} UTC, dat is over {n}.",
"ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.",
"pin": "PIN" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
@ -42,7 +42,6 @@
"title": "Welkom bij Jellyfin", "title": "Welkom bij Jellyfin",
"welcome": "Welkom bij Jellyfin!", "welcome": "Welkom bij Jellyfin!",
"youCanLoginWith": "Je kunt inloggen met onderstaande gegevens", "youCanLoginWith": "Je kunt inloggen met onderstaande gegevens",
"jellyfinURL": "URL", "jellyfinURL": "URL"
"username": "Gebruikersnaam"
} }
} }

View File

@ -2,11 +2,13 @@
"meta": { "meta": {
"name": "Português (BR)" "name": "Português (BR)"
}, },
"strings": {
"ifItWasNotYou": "Se não foi você, ignore este e-mail.",
"helloUser": "Ola {n},"
},
"userCreated": { "userCreated": {
"title": "Aviso: Usuário criado", "title": "Aviso: Usuário criado",
"aUserWasCreated": "Um usuário foi criado usando o código {n}.", "aUserWasCreated": "Um usuário foi criado usando o código {n}.",
"name": "Nome",
"emailAddress": "Endereço",
"time": "Tempo", "time": "Tempo",
"notificationNotice": "Nota: Os emails de notificação podem ser alternados no painel do administrador." "notificationNotice": "Nota: Os emails de notificação podem ser alternados no painel do administrador."
}, },
@ -18,11 +20,9 @@
}, },
"passwordReset": { "passwordReset": {
"title": "Redefinir senha foi solicitada - Jellyfin", "title": "Redefinir senha foi solicitada - Jellyfin",
"helloUser": "Ola {n},",
"someoneHasRequestedReset": "Alguém recentemente solicitou uma redefinição de senha no Jellyfin.", "someoneHasRequestedReset": "Alguém recentemente solicitou uma redefinição de senha no Jellyfin.",
"ifItWasYou": "Se foi você, insira o PIN abaixo.", "ifItWasYou": "Se foi você, insira o PIN abaixo.",
"codeExpiry": "O código irá expirar em {n}, ás {n}, que está em {n}.", "codeExpiry": "O código irá expirar em {n}, ás {n}, que está em {n}.",
"ifItWasNotYou": "Se não foi você, ignore este e-mail.",
"pin": "PIN" "pin": "PIN"
}, },
"userDeleted": { "userDeleted": {
@ -42,7 +42,6 @@
"title": "Bem vindo ao Jellyfin", "title": "Bem vindo ao Jellyfin",
"welcome": "Bem vindo ao Jellyfin!", "welcome": "Bem vindo ao Jellyfin!",
"youCanLoginWith": "Você pode fazer o login com os detalhes abaixo", "youCanLoginWith": "Você pode fazer o login com os detalhes abaixo",
"jellyfinURL": "URL", "jellyfinURL": "URL"
"username": "Nome do Usuário"
} }
} }

View File

@ -14,7 +14,9 @@
"createAccountButton": "Create Account", "createAccountButton": "Create Account",
"passwordRequirementsHeader": "Password Requirements", "passwordRequirementsHeader": "Password Requirements",
"successHeader": "Success!", "successHeader": "Success!",
"successContinueButton": "Continue" "successContinueButton": "Continue",
"confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address."
}, },
"notifications": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",

38
mail/confirmation.mjml Normal file
View File

@ -0,0 +1,38 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>{{ .helloUser }}</p>
<p>{{ .clickBelow }}</p>
<p>{{ .ifItWasNotYou }}</p>
</mj-text>
<mj-button mj-class="blue bold" href="{{ .urlVal }}">{{ .confirmEmail }}</mj-button>
</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>

8
mail/confirmation.txt Normal file
View File

@ -0,0 +1,8 @@
{{ .helloUser }}
{{ .clickBelow }}
{{ .ifItWasNotYou }}
{{ .urlVal }}
{{ .message }}

View File

@ -42,6 +42,7 @@ type Invite struct {
Notify map[string]map[string]bool `json:"notify"` Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"` Profile string `json:"profile"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Keys []string `json"keys,omitempty"`
} }
type Lang struct { type Lang struct {
@ -286,6 +287,7 @@ func (st *Storage) loadLangEmail() error {
if err != nil { if err != nil {
return err return err
} }
st.lang.Common.patchCommon(index, &lang.Strings)
if fname != "en-us.json" { if fname != "en-us.json" {
patchLang(&english.UserCreated, &lang.UserCreated) patchLang(&english.UserCreated, &lang.UserCreated)
patchLang(&english.InviteExpiry, &lang.InviteExpiry) patchLang(&english.InviteExpiry, &lang.InviteExpiry)

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,8 @@ interface formWindow extends Window {
modal: Modal; modal: Modal;
code: string; code: string;
messages: { [key: string]: string }; messages: { [key: string]: string };
confirmation: boolean;
confirmationModal: Modal
} }
interface pwValString { interface pwValString {
@ -26,7 +28,10 @@ interface pwValStrings {
loadLangSelector("form"); loadLangSelector("form");
window.modal = new Modal(document.getElementById("modal-success")); window.modal = new Modal(document.getElementById("modal-success"), true);
if (window.confirmation) {
window.confirmationModal = new Modal(document.getElementById("modal-confirmation"), true);
}
declare var window: formWindow; declare var window: formWindow;
var defaultPwValStrings: pwValStrings = { var defaultPwValStrings: pwValStrings = {
@ -120,6 +125,10 @@ const create = (event: SubmitEvent) => {
toggleLoader(submitSpan); toggleLoader(submitSpan);
if (req.status == 401) { if (req.status == 401) {
if (req.response["error"] as string) { if (req.response["error"] as string) {
if (req.response["error"] == "confirmEmail") {
window.confirmationModal.show();
return;
}
const old = submitSpan.textContent; const old = submitSpan.textContent;
if (req.response["error"] in window.messages) { if (req.response["error"] in window.messages) {
submitSpan.textContent = window.messages[req.response["error"]]; submitSpan.textContent = window.messages[req.response["error"]];

113
views.go
View File

@ -2,8 +2,11 @@ package main
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -78,33 +81,99 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
} }
/* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */ /* Don't actually check if the invite is valid, just if it exists, just so the page loads quicker. Invite is actually checked on submit anyway. */
// if app.checkInvite(code, false, "") { // if app.checkInvite(code, false, "") {
if _, ok := app.storage.invites[code]; ok { inv, ok := app.storage.invites[code]
email := app.storage.invites[code].Email if !ok {
if strings.Contains(email, "Failed") {
email = ""
}
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{
"urlBase": app.URLBase,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(),
"jfLink": app.config.Section("jellyfin").Key("public_server").String(),
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(),
"email": email,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Form[lang].Strings,
"validationStrings": app.storage.lang.Form[lang].validationStringsJSON,
"notifications": app.storage.lang.Form[lang].notificationsJSON,
"code": code,
})
} else {
gcHTML(gc, 404, "invalidCode.html", gin.H{ gcHTML(gc, 404, "invalidCode.html", gin.H{
"cssClass": app.cssClass, "cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(), "contactMessage": app.config.Section("ui").Key("contact_message").String(),
}) })
return
} }
if key := gc.Query("key"); key != "" {
validKey := false
keyIndex := -1
for i, k := range inv.Keys {
if k == key {
validKey = true
keyIndex = i
break
}
}
fail := func() {
gcHTML(gc, 404, "404.html", gin.H{
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
if !validKey {
fail()
return
}
token, err := jwt.Parse(key, checkToken)
if err != nil {
fail()
app.err.Printf("Failed to parse key: %s", err)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64)
if err != nil {
fail()
app.err.Printf("Failed to parse key expiry: %s", err)
return
}
expiry := time.Unix(expiryUnix, 0)
if !(ok && token.Valid && claims["invite"].(string) == code && claims["type"].(string) == "confirmation" && expiry.After(time.Now())) {
fail()
app.debug.Printf("Invalid key")
return
}
req := newUserDTO{
Email: claims["email"].(string),
Username: claims["username"].(string),
Password: claims["password"].(string),
Code: claims["invite"].(string),
}
f, success := app.newUser(req, true)
if !success {
f(gc)
fail()
return
}
gcHTML(gc, http.StatusOK, "create-success.html", gin.H{
"strings": app.storage.lang.Form[lang].Strings,
"successMessage": app.config.Section("ui").Key("success_message").String(),
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
inv, ok := app.storage.invites[code]
if ok {
l := len(inv.Keys)
inv.Keys[l-1], inv.Keys[keyIndex] = inv.Keys[keyIndex], inv.Keys[l-1]
app.storage.invites[code] = inv
}
return
}
email := app.storage.invites[code].Email
if strings.Contains(email, "Failed") {
email = ""
}
gcHTML(gc, http.StatusOK, "form-loader.html", gin.H{
"urlBase": app.URLBase,
"cssClass": app.cssClass,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
"helpMessage": app.config.Section("ui").Key("help_message").String(),
"successMessage": app.config.Section("ui").Key("success_message").String(),
"jfLink": app.config.Section("jellyfin").Key("public_server").String(),
"validate": app.config.Section("password_validation").Key("enabled").MustBool(false),
"requirements": app.validator.getCriteria(),
"email": email,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Form[lang].Strings,
"validationStrings": app.storage.lang.Form[lang].validationStringsJSON,
"notifications": app.storage.lang.Form[lang].notificationsJSON,
"code": code,
"confirmation": app.config.Section("email_confirmation").Key("enabled").MustBool(false),
})
} }
func (app *appContext) NoRouteHandler(gc *gin.Context) { func (app *appContext) NoRouteHandler(gc *gin.Context) {