mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00: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:
parent
736c39840f
commit
ee026714d4
@ -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>
|
187
api.go
187
api.go
@ -3,10 +3,13 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/knz/strtime"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
@ -115,23 +118,27 @@ func (app *appContext) checkInvites() {
|
||||
notify := data.Notify
|
||||
if app.config.Section("notifications").Key("enabled").MustBool(false) && len(notify) != 0 {
|
||||
app.debug.Printf("%s: Expiry notification", code)
|
||||
var wait sync.WaitGroup
|
||||
for address, settings := range notify {
|
||||
if !settings["notify-expiry"] {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
wait.Add(1)
|
||||
go func(addr string) {
|
||||
defer wait.Done()
|
||||
msg, err := app.email.constructExpiry(code, data, app)
|
||||
if err != nil {
|
||||
app.err.Printf("%s: Failed to construct expiry notification", code)
|
||||
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.debug.Printf("Error: %s", err)
|
||||
} else {
|
||||
app.info.Printf("Sent expiry notification to %s", address)
|
||||
app.info.Printf("Sent expiry notification to %s", addr)
|
||||
}
|
||||
}()
|
||||
}(address)
|
||||
}
|
||||
wait.Wait()
|
||||
}
|
||||
changed = true
|
||||
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)
|
||||
} else if newInv.RemainingUses != 0 {
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses -= 1
|
||||
newInv.RemainingUses--
|
||||
}
|
||||
newInv.UsedBy = append(newInv.UsedBy, []string{username, app.formatDatetime(currentTime)})
|
||||
if !del {
|
||||
@ -312,47 +319,67 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
respondUser(200, true, true, "", gc)
|
||||
}
|
||||
|
||||
// @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)
|
||||
gc.Abort()
|
||||
return
|
||||
}
|
||||
type errorFunc func(gc *gin.Context)
|
||||
|
||||
func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
|
||||
existingUser, _, _ := app.jf.UserByName(req.Username, false)
|
||||
if existingUser != nil {
|
||||
msg := fmt.Sprintf("User %s", req.Username)
|
||||
app.info.Printf("%s New user failed: %s", req.Code, msg)
|
||||
f = func(gc *gin.Context) {
|
||||
msg := fmt.Sprintf("User %s already exists", req.Username)
|
||||
app.info.Printf("%s: New user failed: %s", req.Code, msg)
|
||||
respond(401, "errorUserExists", gc)
|
||||
}
|
||||
success = false
|
||||
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)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
f = func(gc *gin.Context) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
for _, val := range validation {
|
||||
if !val {
|
||||
@ -1317,54 +1382,6 @@ func (app *appContext) ServeLang(gc *gin.Context) {
|
||||
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!
|
||||
func (app *appContext) Restart() error {
|
||||
RESTART <- true
|
||||
|
@ -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_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_text").SetValue(app.config.Section("notifications").Key("expiry_text").MustString(filepath.Join(app.localPath, "expired.txt")))
|
||||
|
||||
|
@ -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": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
|
47
email.go
47
email.go
@ -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) {
|
||||
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
|
||||
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
|
||||
err = tpl.Execute(&tplData, map[string]string{
|
||||
"aUserWasCreated": emailer.lang.UserCreated.format("aUserWasCreated", "\""+code+"\""),
|
||||
"name": emailer.lang.UserCreated.get("name"),
|
||||
"address": emailer.lang.UserCreated.get("emailAddress"),
|
||||
"name": emailer.lang.Strings.get("name"),
|
||||
"address": emailer.lang.Strings.get("emailAddress"),
|
||||
"time": emailer.lang.UserCreated.get("time"),
|
||||
"nameVal": username,
|
||||
"addressVal": tplAddress,
|
||||
@ -274,11 +309,11 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Ema
|
||||
}
|
||||
var tplData bytes.Buffer
|
||||
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"),
|
||||
"ifItWasYou": emailer.lang.PasswordReset.get("ifItWasYou"),
|
||||
"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"),
|
||||
"pinVal": pwr.Pin,
|
||||
"message": message,
|
||||
@ -339,7 +374,7 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Ema
|
||||
"youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"),
|
||||
"jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"),
|
||||
"jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(),
|
||||
"username": emailer.lang.WelcomeEmail.get("username"),
|
||||
"username": emailer.lang.Strings.get("username"),
|
||||
"usernameVal": username,
|
||||
"message": app.config.Section("email").Key("message").String(),
|
||||
})
|
||||
|
18
html/create-success.html
Normal file
18
html/create-success.html
Normal 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>
|
@ -6,6 +6,7 @@
|
||||
window.URLBase = "{{ .urlBase }}";
|
||||
window.code = "{{ .code }}";
|
||||
window.messages = JSON.parse({{ .notifications }});
|
||||
window.confirmation = {{ .confirmation }};
|
||||
</script>
|
||||
<script src="js/form.js" type="module"></script>
|
||||
{{ end }}
|
||||
|
@ -13,6 +13,12 @@
|
||||
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.successContinueButton }}</a>
|
||||
</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="button ~urge dropdown-button">
|
||||
<i class="ri-global-line"></i>
|
||||
|
2
lang.go
2
lang.go
@ -78,12 +78,14 @@ func (ls *emailLangs) getOptions(chosen string) (string, []string) {
|
||||
|
||||
type emailLang struct {
|
||||
Meta langMeta `json:"meta"`
|
||||
Strings langSection `json:"strings"`
|
||||
UserCreated langSection `json:"userCreated"`
|
||||
InviteExpiry langSection `json:"inviteExpiry"`
|
||||
PasswordReset langSection `json:"passwordReset"`
|
||||
UserDeleted langSection `json:"userDeleted"`
|
||||
InviteEmail langSection `json:"inviteEmail"`
|
||||
WelcomeEmail langSection `json:"welcomeEmail"`
|
||||
EmailConfirmation langSection `json:"emailConfirmation"`
|
||||
}
|
||||
|
||||
type setupLangs map[string]setupLang
|
||||
|
@ -4,6 +4,7 @@
|
||||
},
|
||||
"strings": {
|
||||
"username": "Benutzername",
|
||||
"name": "Name",
|
||||
"password": "Passwort",
|
||||
"emailAddress": "E-Mail-Adresse",
|
||||
"submit": "Absenden",
|
||||
|
@ -6,6 +6,7 @@
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"emailAddress": "Email Address",
|
||||
"name": "Name",
|
||||
"submit": "Submit",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
|
@ -5,6 +5,7 @@
|
||||
},
|
||||
"strings": {
|
||||
"username": "Nom d'utilisateur",
|
||||
"name": "Nom",
|
||||
"password": "Mot de passe",
|
||||
"emailAddress": "Addresse Email",
|
||||
"submit": "Soumettre",
|
||||
|
@ -4,6 +4,7 @@
|
||||
},
|
||||
"strings": {
|
||||
"username": "Gebruikersnaam",
|
||||
"name": "Naam",
|
||||
"password": "Wachtwoord",
|
||||
"emailAddress": "E-mailadres",
|
||||
"submit": "Verstuur",
|
||||
|
@ -4,6 +4,7 @@
|
||||
},
|
||||
"strings": {
|
||||
"username": "Nome do Usuário",
|
||||
"name": "Nome",
|
||||
"password": "Senha",
|
||||
"emailAddress": "Endereço de Email",
|
||||
"submit": "Enviar",
|
||||
|
@ -2,11 +2,13 @@
|
||||
"meta": {
|
||||
"name": "Deutsch (DE)"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
|
||||
"helloUser": "Hallo {n},"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Mitteilung: Benutzer erstellt",
|
||||
"aUserWasCreated": "Ein Benutzer wurde unter Verwendung des Codes {n] erstellt.",
|
||||
"name": "Name",
|
||||
"emailAddress": "Adresse",
|
||||
"time": "Zeit",
|
||||
"notificationNotice": "Hinweis: Benachrichtigungs-E-Mails können auf dem Administrator-Dashboard umgeschalten werden."
|
||||
},
|
||||
@ -18,11 +20,9 @@
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Passwortzurücksetzung angefordert - Jellyfin",
|
||||
"helloUser": "Hallo {n},",
|
||||
"someoneHasRequestedReset": "Jemand hat vor kurzem eine Passwortzurücksetzung auf Jellyfin angefordert.",
|
||||
"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.",
|
||||
"ifItWasNotYou": "Wenn du das nicht warst, ignoriere bitte diese E-Mail.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
@ -42,7 +42,6 @@
|
||||
"title": "Wilkommen bei Jellyfin",
|
||||
"welcome": "Willkommen bei Jellyfin!",
|
||||
"youCanLoginWith": "Du kannst dich mit den mit den untenstehenden Zugangsdaten anmelden",
|
||||
"jellyfinURL": "URL",
|
||||
"username": "Benutzername"
|
||||
"jellyfinURL": "URL"
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,13 @@
|
||||
"meta": {
|
||||
"name": "English (US)"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
|
||||
"helloUser": "Hi {n},"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Notice: User created",
|
||||
"aUserWasCreated": "A user was created using code {n}.",
|
||||
"name": "Name",
|
||||
"emailAddress": "Address",
|
||||
"time": "Time",
|
||||
"notificationNotice": "Note: Notification emails can be toggled on the admin dashboard."
|
||||
},
|
||||
@ -18,11 +20,9 @@
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Password reset requested - Jellyfin",
|
||||
"helloUser": "Hi {n},",
|
||||
"someoneHasRequestedReset": "Someone has recently requested a password reset on Jellyfin.",
|
||||
"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}.",
|
||||
"ifItWasNotYou": "If this wasn't you, please ignore this email.",
|
||||
"pin": "PIN"
|
||||
},
|
||||
"userDeleted": {
|
||||
@ -42,7 +42,11 @@
|
||||
"title": "Welcome to Jellyfin",
|
||||
"welcome": "Welcome to Jellyfin!",
|
||||
"youCanLoginWith": "You can login with the details below",
|
||||
"jellyfinURL": "URL",
|
||||
"username": "Username"
|
||||
"jellyfinURL": "URL"
|
||||
},
|
||||
"emailConfirmation": {
|
||||
"title": "Confirm your email - Jellyfin",
|
||||
"clickBelow": "Click the link below to confirm your email address and start using Jellyfin.",
|
||||
"confirmEmail": "Confirm Email"
|
||||
}
|
||||
}
|
||||
|
@ -3,11 +3,13 @@
|
||||
"name": "Français (FR)",
|
||||
"author": "https://github.com/Cornichon420"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "Si ce n'était pas toi, tu peux ignorer ce mail.",
|
||||
"helloUser": "Salut {n},"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Notification : Utilisateur créé",
|
||||
"aUserWasCreated": "Un utilisateur a été créé avec ce code {n}.",
|
||||
"name": "Nom",
|
||||
"emailAddress": "Adresse",
|
||||
"time": "Date",
|
||||
"notificationNotice": "Note : Les emails de notification peuvent être activés sur le tableau de bord administrateur."
|
||||
},
|
||||
@ -19,11 +21,9 @@
|
||||
},
|
||||
"passwordReset": {
|
||||
"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.",
|
||||
"ifItWasYou": "Si c'était bien toi, renseigne le code PIN en dessous.",
|
||||
"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"
|
||||
},
|
||||
"userDeleted": {
|
||||
@ -43,7 +43,6 @@
|
||||
"youCanLoginWith": "Tu peux te connecter avec les informations ci-dessous",
|
||||
"title": "Bienvenue sur Jellyfin",
|
||||
"welcome": "Bienvenue sur Jellyfin !",
|
||||
"jellyfinURL": "URL",
|
||||
"username": "Nom d'utilisateur"
|
||||
"jellyfinURL": "URL"
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,13 @@
|
||||
"meta": {
|
||||
"name": "Nederlands (NL)"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "Als jij dit niet was, negeer dan alsjeblieft deze email.",
|
||||
"helloUser": "Hoi {n},"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Melding: Gebruiker aangemaakt",
|
||||
"aUserWasCreated": "Er is een gebruiker aangemaakt door gebruik te maken van code {n}.",
|
||||
"name": "Naam",
|
||||
"emailAddress": "Adres",
|
||||
"time": "Tijdstip",
|
||||
"notificationNotice": "Opmerking: Meldingsemails kunnen worden aan- of uitgezet via het admin dashboard."
|
||||
},
|
||||
@ -18,11 +20,9 @@
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Wachtwoordreset aangevraagd - Jellyfin",
|
||||
"helloUser": "Hoi {n},",
|
||||
"someoneHasRequestedReset": "Iemand heeft recentelijk een wachtwoordreset aangevraagd in Jellyfin.",
|
||||
"ifItWasYou": "Als jij dit was, voor dan onderstaande PIN in.",
|
||||
"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"
|
||||
},
|
||||
"userDeleted": {
|
||||
@ -42,7 +42,6 @@
|
||||
"title": "Welkom bij Jellyfin",
|
||||
"welcome": "Welkom bij Jellyfin!",
|
||||
"youCanLoginWith": "Je kunt inloggen met onderstaande gegevens",
|
||||
"jellyfinURL": "URL",
|
||||
"username": "Gebruikersnaam"
|
||||
"jellyfinURL": "URL"
|
||||
}
|
||||
}
|
||||
|
@ -2,11 +2,13 @@
|
||||
"meta": {
|
||||
"name": "Português (BR)"
|
||||
},
|
||||
"strings": {
|
||||
"ifItWasNotYou": "Se não foi você, ignore este e-mail.",
|
||||
"helloUser": "Ola {n},"
|
||||
},
|
||||
"userCreated": {
|
||||
"title": "Aviso: Usuário criado",
|
||||
"aUserWasCreated": "Um usuário foi criado usando o código {n}.",
|
||||
"name": "Nome",
|
||||
"emailAddress": "Endereço",
|
||||
"time": "Tempo",
|
||||
"notificationNotice": "Nota: Os emails de notificação podem ser alternados no painel do administrador."
|
||||
},
|
||||
@ -18,11 +20,9 @@
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Redefinir senha foi solicitada - Jellyfin",
|
||||
"helloUser": "Ola {n},",
|
||||
"someoneHasRequestedReset": "Alguém recentemente solicitou uma redefinição de senha no Jellyfin.",
|
||||
"ifItWasYou": "Se foi você, insira o PIN abaixo.",
|
||||
"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"
|
||||
},
|
||||
"userDeleted": {
|
||||
@ -42,7 +42,6 @@
|
||||
"title": "Bem vindo ao Jellyfin",
|
||||
"welcome": "Bem vindo ao Jellyfin!",
|
||||
"youCanLoginWith": "Você pode fazer o login com os detalhes abaixo",
|
||||
"jellyfinURL": "URL",
|
||||
"username": "Nome do Usuário"
|
||||
"jellyfinURL": "URL"
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,9 @@
|
||||
"createAccountButton": "Create Account",
|
||||
"passwordRequirementsHeader": "Password Requirements",
|
||||
"successHeader": "Success!",
|
||||
"successContinueButton": "Continue"
|
||||
"successContinueButton": "Continue",
|
||||
"confirmationRequired": "Email confirmation required",
|
||||
"confirmationRequiredMessage": "Please check your email inbox to verify your address."
|
||||
},
|
||||
"notifications": {
|
||||
"errorUserExists": "User already exists.",
|
||||
|
38
mail/confirmation.mjml
Normal file
38
mail/confirmation.mjml
Normal 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
8
mail/confirmation.txt
Normal file
@ -0,0 +1,8 @@
|
||||
{{ .helloUser }}
|
||||
|
||||
{{ .clickBelow }}
|
||||
{{ .ifItWasNotYou }}
|
||||
|
||||
{{ .urlVal }}
|
||||
|
||||
{{ .message }}
|
@ -42,6 +42,7 @@ type Invite struct {
|
||||
Notify map[string]map[string]bool `json:"notify"`
|
||||
Profile string `json:"profile"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Keys []string `json"keys,omitempty"`
|
||||
}
|
||||
|
||||
type Lang struct {
|
||||
@ -286,6 +287,7 @@ func (st *Storage) loadLangEmail() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.lang.Common.patchCommon(index, &lang.Strings)
|
||||
if fname != "en-us.json" {
|
||||
patchLang(&english.UserCreated, &lang.UserCreated)
|
||||
patchLang(&english.InviteExpiry, &lang.InviteExpiry)
|
||||
|
11
ts/form.ts
11
ts/form.ts
@ -8,6 +8,8 @@ interface formWindow extends Window {
|
||||
modal: Modal;
|
||||
code: string;
|
||||
messages: { [key: string]: string };
|
||||
confirmation: boolean;
|
||||
confirmationModal: Modal
|
||||
}
|
||||
|
||||
interface pwValString {
|
||||
@ -26,7 +28,10 @@ interface pwValStrings {
|
||||
|
||||
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;
|
||||
|
||||
var defaultPwValStrings: pwValStrings = {
|
||||
@ -120,6 +125,10 @@ const create = (event: SubmitEvent) => {
|
||||
toggleLoader(submitSpan);
|
||||
if (req.status == 401) {
|
||||
if (req.response["error"] as string) {
|
||||
if (req.response["error"] == "confirmEmail") {
|
||||
window.confirmationModal.show();
|
||||
return;
|
||||
}
|
||||
const old = submitSpan.textContent;
|
||||
if (req.response["error"] in window.messages) {
|
||||
submitSpan.textContent = window.messages[req.response["error"]];
|
||||
|
83
views.go
83
views.go
@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@ -78,7 +81,78 @@ 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. */
|
||||
// if app.checkInvite(code, false, "") {
|
||||
if _, ok := app.storage.invites[code]; ok {
|
||||
inv, ok := app.storage.invites[code]
|
||||
if !ok {
|
||||
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
||||
"cssClass": app.cssClass,
|
||||
"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 = ""
|
||||
@ -98,13 +172,8 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
|
||||
"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),
|
||||
})
|
||||
} else {
|
||||
gcHTML(gc, 404, "invalidCode.html", gin.H{
|
||||
"cssClass": app.cssClass,
|
||||
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) NoRouteHandler(gc *gin.Context) {
|
||||
|
Loading…
Reference in New Issue
Block a user