From ee026714d470277cae92de4ffe80444006dc6b55 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sat, 30 Jan 2021 19:19:12 +0000 Subject: [PATCH] Add optional email confirmation If enabled, a confirmation email will be sent before the user can create their account. --- README.md.old | 38 -------- api.go | 193 +++++++++++++++++++++------------------ config.go | 4 + config/config-base.json | 40 ++++++++ email.go | 47 ++++++++-- html/create-success.html | 18 ++++ html/form-base.html | 1 + html/form.html | 6 ++ lang.go | 16 ++-- lang/common/de-de.json | 1 + lang/common/en-us.json | 1 + lang/common/fr-fr.json | 1 + lang/common/nl-nl.json | 1 + lang/common/pt-br.json | 1 + lang/email/de-de.json | 11 +-- lang/email/en-us.json | 16 ++-- lang/email/fr-fr.json | 11 +-- lang/email/nl-nl.json | 11 +-- lang/email/pt-br.json | 11 +-- lang/form/en-us.json | 4 +- mail/confirmation.mjml | 38 ++++++++ mail/confirmation.txt | 8 ++ storage.go | 2 + test.css | 1 - ts/form.ts | 11 ++- views.go | 113 ++++++++++++++++++----- 26 files changed, 411 insertions(+), 194 deletions(-) delete mode 100644 README.md.old create mode 100644 html/create-success.html create mode 100644 mail/confirmation.mjml create mode 100644 mail/confirmation.txt delete mode 100644 test.css diff --git a/README.md.old b/README.md.old deleted file mode 100644 index c27a77e..0000000 --- a/README.md.old +++ /dev/null @@ -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 -

- invites - accounts - settings - login modal - modify user settings modal -

- -##### light -

- invites - accounts - settings - login modal - modify user settings modal -

diff --git a/api.go b/api.go index ee19501..65aaab5 100644 --- a/api.go +++ b/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) - respond(401, "errorUserExists", gc) + 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 { - 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) + 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 diff --git a/config.go b/config.go index f4dc955..e429d02 100644 --- a/config.go +++ b/config.go @@ -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"))) diff --git a/config/config-base.json b/config/config-base.json index c25d7b5..06d02bc 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -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": { diff --git a/email.go b/email.go index 2fbbce3..2c1f95b 100644 --- a/email.go +++ b/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(), }) diff --git a/html/create-success.html b/html/create-success.html new file mode 100644 index 0000000..f7a69ef --- /dev/null +++ b/html/create-success.html @@ -0,0 +1,18 @@ + + + + + {{ template "header.html" . }} + {{ .strings.successHeader }} - jfa-go + + +
+
+ {{ .strings.successHeader }} +

{{ .successMessage }}

+ {{ .strings.successContinueButton }} +
+ {{ .contactMessage }} +
+ + diff --git a/html/form-base.html b/html/form-base.html index 03be6c1..8a57868 100644 --- a/html/form-base.html +++ b/html/form-base.html @@ -6,6 +6,7 @@ window.URLBase = "{{ .urlBase }}"; window.code = "{{ .code }}"; window.messages = JSON.parse({{ .notifications }}); + window.confirmation = {{ .confirmation }}; {{ end }} diff --git a/html/form.html b/html/form.html index 31da74d..adb0117 100644 --- a/html/form.html +++ b/html/form.html @@ -13,6 +13,12 @@ {{ .strings.successContinueButton }} + diff --git a/lang.go b/lang.go index 169ba8f..8e8a1f5 100644 --- a/lang.go +++ b/lang.go @@ -77,13 +77,15 @@ func (ls *emailLangs) getOptions(chosen string) (string, []string) { } type emailLang struct { - Meta langMeta `json:"meta"` - UserCreated langSection `json:"userCreated"` - InviteExpiry langSection `json:"inviteExpiry"` - PasswordReset langSection `json:"passwordReset"` - UserDeleted langSection `json:"userDeleted"` - InviteEmail langSection `json:"inviteEmail"` - WelcomeEmail langSection `json:"welcomeEmail"` + 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 diff --git a/lang/common/de-de.json b/lang/common/de-de.json index f14f982..d7daaf9 100644 --- a/lang/common/de-de.json +++ b/lang/common/de-de.json @@ -4,6 +4,7 @@ }, "strings": { "username": "Benutzername", + "name": "Name", "password": "Passwort", "emailAddress": "E-Mail-Adresse", "submit": "Absenden", diff --git a/lang/common/en-us.json b/lang/common/en-us.json index 21f0aa8..1e5360b 100644 --- a/lang/common/en-us.json +++ b/lang/common/en-us.json @@ -6,6 +6,7 @@ "username": "Username", "password": "Password", "emailAddress": "Email Address", + "name": "Name", "submit": "Submit", "success": "Success", "error": "Error", diff --git a/lang/common/fr-fr.json b/lang/common/fr-fr.json index 6f2ee01..a59a2c5 100644 --- a/lang/common/fr-fr.json +++ b/lang/common/fr-fr.json @@ -5,6 +5,7 @@ }, "strings": { "username": "Nom d'utilisateur", + "name": "Nom", "password": "Mot de passe", "emailAddress": "Addresse Email", "submit": "Soumettre", diff --git a/lang/common/nl-nl.json b/lang/common/nl-nl.json index 0d1e4d6..08edcec 100644 --- a/lang/common/nl-nl.json +++ b/lang/common/nl-nl.json @@ -4,6 +4,7 @@ }, "strings": { "username": "Gebruikersnaam", + "name": "Naam", "password": "Wachtwoord", "emailAddress": "E-mailadres", "submit": "Verstuur", diff --git a/lang/common/pt-br.json b/lang/common/pt-br.json index ef6e9d1..ca6b0b5 100644 --- a/lang/common/pt-br.json +++ b/lang/common/pt-br.json @@ -4,6 +4,7 @@ }, "strings": { "username": "Nome do Usuário", + "name": "Nome", "password": "Senha", "emailAddress": "Endereço de Email", "submit": "Enviar", diff --git a/lang/email/de-de.json b/lang/email/de-de.json index eb9359a..1c3edd0 100644 --- a/lang/email/de-de.json +++ b/lang/email/de-de.json @@ -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" } } diff --git a/lang/email/en-us.json b/lang/email/en-us.json index 928a89b..e2d4f89 100644 --- a/lang/email/en-us.json +++ b/lang/email/en-us.json @@ -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" } } diff --git a/lang/email/fr-fr.json b/lang/email/fr-fr.json index ddab493..c6bc652 100644 --- a/lang/email/fr-fr.json +++ b/lang/email/fr-fr.json @@ -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" } } diff --git a/lang/email/nl-nl.json b/lang/email/nl-nl.json index fc410c7..b07b788 100644 --- a/lang/email/nl-nl.json +++ b/lang/email/nl-nl.json @@ -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" } } diff --git a/lang/email/pt-br.json b/lang/email/pt-br.json index d862849..294cf62 100644 --- a/lang/email/pt-br.json +++ b/lang/email/pt-br.json @@ -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" } } diff --git a/lang/form/en-us.json b/lang/form/en-us.json index d4c6732..3c4d634 100644 --- a/lang/form/en-us.json +++ b/lang/form/en-us.json @@ -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.", diff --git a/mail/confirmation.mjml b/mail/confirmation.mjml new file mode 100644 index 0000000..5118e6d --- /dev/null +++ b/mail/confirmation.mjml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

{{ .helloUser }}

+

{{ .clickBelow }}

+

{{ .ifItWasNotYou }}

+
+ {{ .confirmEmail }} +
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/confirmation.txt b/mail/confirmation.txt new file mode 100644 index 0000000..e6a3606 --- /dev/null +++ b/mail/confirmation.txt @@ -0,0 +1,8 @@ +{{ .helloUser }} + +{{ .clickBelow }} +{{ .ifItWasNotYou }} + +{{ .urlVal }} + +{{ .message }} diff --git a/storage.go b/storage.go index 3153417..f0562f7 100644 --- a/storage.go +++ b/storage.go @@ -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) diff --git a/test.css b/test.css deleted file mode 100644 index 32e724e..0000000 --- a/test.css +++ /dev/null @@ -1 +0,0 @@ -@import"remixicon.css";:root{--color-neutral-000-fallback:var(--color-neutral-000,#fff);--color-neutral-50-fallback:var(--color-neutral-50,#f9fafb);--color-neutral-100-fallback:var(--color-neutral-100,#f3f4f6);--color-neutral-200-fallback:var(--color-neutral-200,#e5e7eb);--color-neutral-300-fallback:var(--color-neutral-300,#d1d5db);--color-neutral-400-fallback:var(--color-neutral-400,#9ca3af);--color-neutral-500-fallback:var(--color-neutral-500,#6b7280);--color-neutral-600-fallback:var(--color-neutral-600,#4b5563);--color-neutral-700-fallback:var(--color-neutral-700,#374151);--color-neutral-800-fallback:var(--color-neutral-800,#1f2937);--color-neutral-900-fallback:var(--color-neutral-900,#111827);--color-positive-000-fallback:var(--color-positive-000,#fff);--color-positive-50-fallback:var(--color-positive-50,#f0fdf4);--color-positive-100-fallback:var(--color-positive-100,#dcfce7);--color-positive-200-fallback:var(--color-positive-200,#bbf7d0);--color-positive-300-fallback:var(--color-positive-300,#86efac);--color-positive-400-fallback:var(--color-positive-400,#4ade80);--color-positive-500-fallback:var(--color-positive-500,#22c55e);--color-positive-600-fallback:var(--color-positive-600,#16a34a);--color-positive-700-fallback:var(--color-positive-700,#15803d);--color-positive-800-fallback:var(--color-positive-800,#166534);--color-positive-900-fallback:var(--color-positive-900,#14532d);--color-warning-000-fallback:var(--color-warning-000,#fff);--color-warning-50-fallback:var(--color-warning-50,#fffbeb);--color-warning-100-fallback:var(--color-warning-100,#fef3c7);--color-warning-200-fallback:var(--color-warning-200,#fde68a);--color-warning-300-fallback:var(--color-warning-300,#fcd34d);--color-warning-400-fallback:var(--color-warning-400,#fbbf24);--color-warning-500-fallback:var(--color-warning-500,#f59e0b);--color-warning-600-fallback:var(--color-warning-600,#d97706);--color-warning-700-fallback:var(--color-warning-700,#b45309);--color-warning-800-fallback:var(--color-warning-800,#92400e);--color-warning-900-fallback:var(--color-warning-900,#78350f);--color-critical-000-fallback:var(--color-critical-000,#fff);--color-critical-50-fallback:var(--color-critical-50,#fef2f2);--color-critical-100-fallback:var(--color-critical-100,#fee2e2);--color-critical-200-fallback:var(--color-critical-200,#fecaca);--color-critical-300-fallback:var(--color-critical-300,#fca5a5);--color-critical-400-fallback:var(--color-critical-400,#f87171);--color-critical-500-fallback:var(--color-critical-500,#ef4444);--color-critical-600-fallback:var(--color-critical-600,#dc2626);--color-critical-700-fallback:var(--color-critical-700,#b91c1c);--color-critical-800-fallback:var(--color-critical-800,#991b1b);--color-critical-900-fallback:var(--color-critical-900,#7f1d1d);--color-info-000-fallback:var(--color-info-000,#fff);--color-info-50-fallback:var(--color-info-50,#eff6ff);--color-info-100-fallback:var(--color-info-100,#dbeafe);--color-info-200-fallback:var(--color-info-200,#bfdbfe);--color-info-300-fallback:var(--color-info-300,#93c5fd);--color-info-400-fallback:var(--color-info-400,#60a5fa);--color-info-500-fallback:var(--color-info-500,#3b82f6);--color-info-600-fallback:var(--color-info-600,#2563eb);--color-info-700-fallback:var(--color-info-700,#1d4ed8);--color-info-800-fallback:var(--color-info-800,#1e40af);--color-info-900-fallback:var(--color-info-900,#1e3a8a);--color-urge-000-fallback:var(--color-urge-000,#fff);--color-urge-50-fallback:var(--color-urge-50,#f5f3ff);--color-urge-100-fallback:var(--color-urge-100,#ede9fe);--color-urge-200-fallback:var(--color-urge-200,#ddd6fe);--color-urge-300-fallback:var(--color-urge-300,#c4b5fd);--color-urge-400-fallback:var(--color-urge-400,#a78bfa);--color-urge-500-fallback:var(--color-urge-500,#8b5cf6);--color-urge-600-fallback:var(--color-urge-600,#7c3aed);--color-urge-700-fallback:var(--color-urge-700,#6d28d9);--color-urge-800-fallback:var(--color-urge-800,#5b21b6);--color-urge-900-fallback:var(--color-urge-900,#4c1d95);--color-transparent:transparent;--color-neutral:var(--color-neutral-600-fallback);--color-neutral-light:var(--color-neutral-300-fallback);--color-neutral-low-title:var(--color-neutral-900-fallback);--color-neutral-low-content:var(--color-neutral-700-fallback);--color-neutral-low-fill:transparent;--color-neutral-low-background:var(--color-neutral-000-fallback);--color-neutral-low-muted:var(--color-neutral-700-fallback);--color-neutral-normal-content:var(--color-neutral-800-fallback);--color-neutral-normal-fill:var(--color-neutral-200-fallback);--color-neutral-normal-background:var(--color-neutral-50-fallback);--color-neutral-normal-muted:var(--color-neutral-700-fallback);--color-neutral-high-content:var(--color-neutral-000-fallback);--color-neutral-high-fill:var(--color-neutral-700-fallback);--color-neutral-high-muted:var(--color-neutral-300-fallback);--color-positive:var(--color-positive-500-fallback);--color-positive-light:var(--color-positive-300-fallback);--color-positive-low-title:var(--color-positive-700-fallback);--color-positive-low-content:var(--color-positive-600-fallback);--color-positive-low-fill:transparent;--color-positive-low-background:var(--color-positive-000-fallback);--color-positive-low-muted:var(--color-positive-500-fallback);--color-positive-normal-content:var(--color-positive-800-fallback);--color-positive-normal-fill:var(--color-positive-200-fallback);--color-positive-normal-background:var(--color-positive-50-fallback);--color-positive-normal-muted:var(--color-positive-600-fallback);--color-positive-high-content:var(--color-positive-000-fallback);--color-positive-high-fill:var(--color-positive-500-fallback);--color-positive-high-muted:var(--color-positive-50-fallback);--color-warning:var(--color-warning-500-fallback);--color-warning-light:var(--color-warning-300-fallback);--color-warning-low-title:var(--color-warning-800-fallback);--color-warning-low-content:var(--color-warning-700-fallback);--color-warning-low-fill:transparent;--color-warning-low-background:var(--color-warning-000-fallback);--color-warning-low-muted:var(--color-warning-600-fallback);--color-warning-normal-content:var(--color-warning-800-fallback);--color-warning-normal-fill:var(--color-warning-200-fallback);--color-warning-normal-background:var(--color-warning-50-fallback);--color-warning-normal-muted:var(--color-warning-600-fallback);--color-warning-high-content:var(--color-warning-900-fallback);--color-warning-high-fill:var(--color-warning-400-fallback);--color-warning-high-muted:var(--color-warning-800-fallback);--color-critical:var(--color-critical-500-fallback);--color-critical-light:var(--color-critical-300-fallback);--color-critical-low-title:var(--color-critical-700-fallback);--color-critical-low-content:var(--color-critical-600-fallback);--color-critical-low-fill:transparent;--color-critical-low-background:var(--color-critical-000-fallback);--color-critical-low-muted:var(--color-critical-500-fallback);--color-critical-normal-content:var(--color-critical-800-fallback);--color-critical-normal-fill:var(--color-critical-200-fallback);--color-critical-normal-background:var(--color-critical-50-fallback);--color-critical-normal-muted:var(--color-critical-600-fallback);--color-critical-high-content:var(--color-critical-000-fallback);--color-critical-high-fill:var(--color-critical-500-fallback);--color-critical-high-muted:var(--color-critical-50-fallback);--color-info:var(--color-info-500-fallback);--color-info-light:var(--color-info-300-fallback);--color-info-low-title:var(--color-info-700-fallback);--color-info-low-content:var(--color-info-600-fallback);--color-info-low-fill:transparent;--color-info-low-background:var(--color-info-000-fallback);--color-info-low-muted:var(--color-info-500-fallback);--color-info-normal-content:var(--color-info-800-fallback);--color-info-normal-fill:var(--color-info-200-fallback);--color-info-normal-background:var(--color-info-50-fallback);--color-info-normal-muted:var(--color-info-600-fallback);--color-info-high-content:var(--color-info-000-fallback);--color-info-high-fill:var(--color-info-500-fallback);--color-info-high-muted:var(--color-info-50-fallback);--color-urge:var(--color-urge-600-fallback);--color-urge-light:var(--color-urge-300-fallback);--color-urge-low-title:var(--color-urge-700-fallback);--color-urge-low-content:var(--color-urge-600-fallback);--color-urge-low-fill:transparent;--color-urge-low-background:var(--color-urge-000-fallback);--color-urge-low-muted:var(--color-urge-500-fallback);--color-urge-normal-content:var(--color-urge-800-fallback);--color-urge-normal-fill:var(--color-urge-200-fallback);--color-urge-normal-background:var(--color-urge-50-fallback);--color-urge-normal-muted:var(--color-urge-600-fallback);--color-urge-high-content:var(--color-urge-000-fallback);--color-urge-high-fill:var(--color-urge-600-fallback);--color-urge-high-muted:var(--color-urge-50-fallback);--color-fill:transparent;--color-background:var(--color-neutral-50-fallback);--color-content:var(--color-neutral-low-content);--color-light:var(--color-neutral-light);--color-title:var(--color-neutral-low-title);--color-icon:var(--color-content);--color-muted:var(--color-neutral-low-muted);--color-core:var(--color-neutral);--fallback-box-shadow-low:var(--box-shadow-sm,0 1px 2px 0 rgba(0,0,0,.05));--fallback-box-shadow-normal:var(--box-shadow-default,0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px 0 rgba(0,0,0,.06));--fallback-box-shadow-high:var(--box-shadow-md,0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -1px rgba(0,0,0,.06));--fallback-box-shadow:var(--fallback-box-shadow-normal);--family-primary:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--family-secondary:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--text-rendering-mode:optimizeLegibility;--body-line-height:1.5}.chev{z-index:10;border:var(--chev-width,1.5px) solid var(--color-icon,var(--color-core));height:var(--chev-size,.5em);width:var(--chev-size,.5em);margin-top:calc(var(--chev-size,.5em) - 20%);transform:rotate(45deg);transform-origin:top;top:50%;content:" ";border-top-width:var(--border-width-0,0);border-left-width:var(--border-width-0,0);align-self:center;margin-top:auto;margin-bottom:auto;top:0;bottom:0}.chev,.loading{pointer-events:none}.loading{color:var(--color-transparent)!important}.loading:after{-webkit-animation:spin var(--loading-speed,.5s) infinite linear;animation:spin var(--loading-speed,.5s) infinite linear;border:var(--loading-width,2px) solid var(--color-content);border-color:var(--color-transparent) var(--color-transparent) var(--color-content) var(--color-content);content:"";height:var(--loading-height,.85em);width:var(--loading-height,.85em);right:calc(50% - var(--loading-height,.85em)/2);bottom:calc(50% - var(--loading-height,.85em)/2);overflow:visible;position:absolute;border-radius:var(--border-radius-full,9999px);margin-left:auto;margin-right:auto}.loading{position:relative}@-webkit-keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.card{border-radius:var(--border-radius-lg,.5rem);display:block;overflow:hidden;padding:var(--spacing-4,1rem);--tw-shadow:var(--box-shadow-lg,0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05));box-shadow:var(--tw-ring-offset-shadow,0 0 transparent),var(--tw-ring-shadow,0 0 transparent),var(--tw-shadow)}.card,.section{color:var(--color-content);background-color:var(--color-background)}.sep{height:var(--sep-height,3rem);background-color:transparent;border-style:none}.aside{border-left:var(--aside-border-width,3px) solid var(--color-core);border-radius:var(--border-radius-lg,.5rem);display:block;overflow:hidden;padding:var(--spacing-4,1rem);color:var(--color-content);background-color:var(--color-background)}.code{background-color:var(--code-background,var(--color-light));color:var(--code-color,var(--color-title));border-radius:var(--border-radius-default,.25rem);display:inline;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-px,1px) var(--spacing-1,.25rem)}.content{font-family:var(--family-primary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-normal,1.5)}.content>:not(:last-child),.content>blockquote :not(:last-child){margin-bottom:var(--spacing-3,.75rem)}.content>:not(:first-child),.content>blockquote :not(:first-child){margin-top:var(--spacing-3,.75rem)}.content h1{color:var(--color-title);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-weight:var(--font-weight-semibold,600);line-height:var(--line-height-tight,1.25)}.content h2,.content h3,.content h4,.content h5,.content h6{color:var(--color-title);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-weight:var(--font-weight-semibold,600);font-size:var(--font-size-3xl,1.875rem);line-height:var(--line-height-tight,1.25)}.content h1:not(:first-child){margin-top:var(--spacing-12,3rem)}.content h2:not(:first-child){margin-top:var(--spacing-10,2.5rem)}.content h3:not(:first-child){margin-top:var(--spacing-8,2rem)}.content h4:not(:first-child),.content h5:not(:first-child),.content h6:not(:first-child){margin-top:var(--spacing-6,1.5rem)}.content code{background-color:var(--code-background,var(--color-light));color:var(--code-color,var(--color-title));border-radius:var(--border-radius-default,.25rem);display:inline;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-px,1px) var(--spacing-1,.25rem)}.content pre code{background-color:unset}.content pre{overflow:auto;background-color:var(--pre-background,var(--color-neutral-200,#e5e7eb));white-space:pre}.content pre code{padding:var(--spacing-0,0)}.content pre{border-radius:var(--border-radius-default,.25rem);display:block;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-2,.5rem)}.content code,.content kbd{margin-left:var(--spacing-px,1px);margin-right:var(--spacing-px,1px)}.content pre code{margin-left:var(--spacing-0,0);margin-right:var(--spacing-0,0)}.content kbd{background-color:var(--kbd-background,var(--color-neutral-700,#374151));color:var(--kbd-color,#fff);border-radius:var(--border-radius-default,.25rem);display:inline;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-px,1px) var(--spacing-1,.25rem)}.content h1{font-size:var(--font-size-3xl,1.875rem)}.content h2{font-size:var(--font-size-2xl,1.5rem)}.content h3{font-size:var(--font-size-xl,1.25rem)}.content h4{font-size:var(--font-size-lg,1.125rem)}.content h5{font-size:var(--font-size-base,1rem)}.content h6{font-size:var(--font-size-sm,.875rem)}.content li{display:list-item;text-align:left}.content ol,.content ul{display:block;margin-left:var(--spacing-8,2rem)}.content ol{list-style-type:decimal}.content ul{list-style-type:disc}.content a:not(.button):not(.portal){color:inherit;text-decoration:underline}.content b,.content strong{font-weight:var(--font-weight-semibold,600)}.content blockquote{border-left:var(--aside-border-width,3px) solid var(--color-core);border-radius:var(--border-radius-lg,.5rem);display:block;overflow:hidden;padding:var(--spacing-4,1rem);color:var(--color-content);background-color:var(--color-background);--color-content:var(--color-neutral-normal-content);--color-fill:var(--color-neutral-normal-fill);--color-background:var(--color-neutral-normal-background,var(--color-fill));--color-muted:var(--color-neutral-normal-muted);--color-title:var(--color-neutral-normal-title,var(--color-neutral-normal-content));--color-core:var(--color-neutral);--color-light:var(--color-neutral-light,var(--color-core));--color-icon:var(--color-neutral-normal-muted)}.content blockquote.\!low{--color-content:var(--color-neutral-low-content);--color-fill:var(--color-neutral-low-fill);--color-background:var(--color-neutral-low-background,var(--color-fill));--color-muted:var(--color-neutral-low-muted);--color-title:var(--color-neutral-low-title,var(--color-neutral-low-content));--color-icon:var(--color-neutral-low-muted)}.content .\~neutralblockquote,.content blockquote.\!normal{--color-content:var(--color-neutral-normal-content);--color-fill:var(--color-neutral-normal-fill);--color-background:var(--color-neutral-normal-background,var(--color-fill));--color-muted:var(--color-neutral-normal-muted);--color-title:var(--color-neutral-normal-title,var(--color-neutral-normal-content));--color-icon:var(--color-neutral-normal-muted)}.content blockquote.\!high{--color-content:var(--color-neutral-high-content);--color-fill:var(--color-neutral-high-fill);--color-background:var(--color-neutral-high-background,var(--color-fill));--color-muted:var(--color-neutral-high-muted);--color-title:var(--color-neutral-high-title,var(--color-neutral-high-content));--color-icon:var(--color-neutral-high-muted)}.content .\~positiveblockquote{--color-content:var(--color-positive-normal-content);--color-fill:var(--color-positive-normal-fill);--color-background:var(--color-positive-normal-background,var(--color-fill));--color-muted:var(--color-positive-normal-muted);--color-title:var(--color-positive-normal-title,var(--color-positive-normal-content));--color-icon:var(--color-positive-normal-muted)}.content .\~warningblockquote{--color-content:var(--color-warning-normal-content);--color-fill:var(--color-warning-normal-fill);--color-background:var(--color-warning-normal-background,var(--color-fill));--color-muted:var(--color-warning-normal-muted);--color-title:var(--color-warning-normal-title,var(--color-warning-normal-content));--color-icon:var(--color-warning-normal-muted)}.content .\~criticalblockquote{--color-content:var(--color-critical-normal-content);--color-fill:var(--color-critical-normal-fill);--color-background:var(--color-critical-normal-background,var(--color-fill));--color-muted:var(--color-critical-normal-muted);--color-title:var(--color-critical-normal-title,var(--color-critical-normal-content));--color-icon:var(--color-critical-normal-muted)}.content .\~infoblockquote{--color-content:var(--color-info-normal-content);--color-fill:var(--color-info-normal-fill);--color-background:var(--color-info-normal-background,var(--color-fill));--color-muted:var(--color-info-normal-muted);--color-title:var(--color-info-normal-title,var(--color-info-normal-content));--color-icon:var(--color-info-normal-muted)}.content .\~urgeblockquote{--color-content:var(--color-urge-normal-content);--color-fill:var(--color-urge-normal-fill);--color-background:var(--color-urge-normal-background,var(--color-fill));--color-muted:var(--color-urge-normal-muted);--color-title:var(--color-urge-normal-title,var(--color-urge-normal-content));--color-icon:var(--color-urge-normal-muted)}.content blockquote{--fallback-box-shadow:var(--fallback-box-shadow-normal)}.content blockquote cite{display:block;margin-top:var(--spacing-1,.25rem);font-style:normal;color:var(--color-muted);font-size:var(--font-size-xs,.75rem)}.content blockquote cite,.content table{font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji")}.content table{display:table;border-collapse:collapse;max-width:100%;overflow:auto;width:100%}.content table thead{border-bottom-width:var(--border-width-default,1px)}.content table tfoot{border-top-width:var(--border-width-default,1px)}.content table tr{display:table-row;vertical-align:inherit;border-color:inherit}.content table td,.content table th{display:table-cell;padding:var(--spacing-2,.5rem);text-align:left}.content table th,.heading{color:var(--color-title);font-weight:var(--font-weight-semibold,600)}.heading{font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-size:var(--font-size-3xl,1.875rem);line-height:var(--line-height-tight,1.25)}.kbd{background-color:var(--kbd-background,var(--color-neutral-700,#374151));color:var(--kbd-color,#fff);border-radius:var(--border-radius-default,.25rem);display:inline;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-px,1px) var(--spacing-1,.25rem)}.label{color:var(--color-title);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-weight:var(--font-weight-medium,500)}.pre{background-color:var(--pre-background,var(--color-neutral-200,#e5e7eb));white-space:pre}.pre code{padding:var(--spacing-0,0)}.pre{border-radius:var(--border-radius-default,.25rem);display:block;font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;padding:var(--spacing-2,.5rem)}.subheading{font-size:var(--font-size-xl,1.25rem)}.subheading,.support{color:var(--color-muted);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji")}.support{font-size:var(--font-size-xs,.75rem)}.supra{text-transform:uppercase;letter-spacing:var(--supra-letter-spacing,.06rem);color:var(--color-muted);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-size:var(--font-size-sm,.875rem)}.chip{font-size:var(--font-size-xs,.75rem);padding:var(--spacing-1,.25rem) var(--spacing-2,.5rem)}.badge,.chip{background-color:var(--color-fill);color:var(--color-content);display:inline-flex;align-items:center;vertical-align:middle;justify-content:center;border-radius:var(--border-radius-default,.25rem);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji")}.badge{align-self:center;font-size:var(--badge-size,.6rem);font-weight:var(--font-weight-medium,500);padding:var(--spacing-px,1px) var(--spacing-1,.25rem)}.icon{color:var(--color-content);align-items:center;justify-content:center;vertical-align:middle;display:inline-flex;width:var(--icon-width,1em);height:var(--icon-height,1em);fill:var(--color-content);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji")}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:var(--progress-height,1em);display:block;overflow:hidden;width:100%;padding:0;border:none;-ms-writing-mode:lr-tb;writing-mode:horizontal-tb}.progress::-webkit-progress-bar{background-color:var(--progress-background-color,var(--color-neutral-300,#d1d5db))}.progress::-webkit-progress-value{background-color:var(--color-core);border:none}.progress::-moz-selection{background-color:var(--color-core);border:none}.progress::selection{background-color:var(--color-core);border:none}.progress::-moz-progress-bar{background-color:var(--color-core);border:none}.progress::-ms-fill{background-color:var(--color-core);border:none}.progress{border-radius:var(--border-radius-xl,1rem)}.progress,.shield{font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji")}.shield{background-color:var(--color-fill);color:var(--color-content);display:inline-flex;vertical-align:middle;justify-content:center;padding:var(--shield-padding-x,1rem) var(--shield-padding-y,1rem);border-radius:var(--border-radius-full,9999px)}.table{border-collapse:collapse;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");max-width:100%;overflow:auto;width:100%}.table thead{border-bottom-width:var(--border-width-default,1px)}.table tfoot{border-top-width:var(--border-width-default,1px)}.table tr{display:table-row;vertical-align:inherit;border-color:inherit}.table td,.table th{display:table-cell;padding:var(--spacing-2,.5rem);text-align:left}.table th{color:var(--color-title);font-weight:var(--font-weight-semibold,600)}.button{background-color:var(--color-fill);color:var(--color-content);display:inline-flex;flex-direction:row;flex-wrap:nowrap;align-items:center;align-content:center;justify-content:space-between;cursor:pointer;position:relative;line-height:var(--button-line-height,1.5);white-space:nowrap;transition:filter var(--button-transition-speed,75ms) linear;transform-origin:center;text-decoration:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--border-radius-default,.25rem);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");outline:2px solid transparent;outline-offset:2px;padding:var(--spacing-1,.25rem) var(--spacing-2,.5rem)}.button:focus:not([disabled]):not(.field),.button:hover:not([disabled]):not(.field){filter:brightness(var(--button-filter-brightness,95%))}.button[disabled]{opacity:.6;cursor:not-allowed}.dropdown{z-index:1;cursor:pointer;position:relative}.dropdown .dropdown-display{visibility:hidden;opacity:0;position:absolute;top:100%;transition:opacity var(--dropdown-animation-speed,.1s);display:block;z-index:10}.dropdown:not(.manual):focus-within .dropdown-display,.dropdown:not(.manual):focus .dropdown-display,.dropdown:not(.manual):hover .dropdown-display{visibility:visible;opacity:1;cursor:default}.dropdown.selected .dropdown-display{visibility:visible;opacity:1;cursor:default}.portal{display:inline-flex;align-items:left;justify-content:left;cursor:pointer;white-space:nowrap;padding:var(--portal-padding,.15em .5em);transition:color var(--portal-transition-speed,75ms) linear;text-decoration:none;border-radius:var(--border-radius-xl,1rem);font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-weight:var(--font-weight-medium,500)}.portal:focus:not([disabled]),.portal:hover:not([disabled]){color:var(--portal-focus-color,var(--color-title))}.portal[disabled]{opacity:var(--portal-disabled-opacity,.6);cursor:not-allowed}.portal.active{background-color:var(--color-fill);color:var(--color-content)}.portal.active:hover{filter:none!important}.field{border-color:var(--color-light);transition:box-shadow var(--field-transition-speed,75ms) ease-out,border var(--field-transition-speed,75ms) ease-out;box-shadow:var(--fallback-box-shadow);appearance:none;background-color:inherit;-webkit-appearance:none;-moz-appearance:none}.field[disabled]{cursor:not-allowed;opacity:var(--field-disabled-opacity,.6)}.field[readonly]{-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all}.field::-moz-placeholder{opacity:.5}.field:-ms-input-placeholder{opacity:.5}.field::placeholder{opacity:.5}.field:hover{box-shadow:var(--field-hover-box-shadow,0 0 0 var(--border-width-default,1px) var(--color-light)),var(--fallback-box-shadow)}.field:focus{box-shadow:var(--field-focus-box-shadow,0 0 0 var(--border-width-2,2px) var(--color-light)),var(--fallback-box-shadow);outline:2px solid transparent;outline-offset:2px}.field{border-radius:var(--border-radius-default,.25rem);border-width:var(--border-width-default,1px);display:inline-flex;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-tight,1.25);max-width:100%;padding:var(--spacing-2,.5rem);width:100%}.button:focus:not([disabled]):not(.input),.button:hover:not([disabled]):not(.input){filter:brightness(var(--button-filter-brightness,95%))}.input{border-color:var(--color-light);transition:box-shadow var(--field-transition-speed,75ms) ease-out,border var(--field-transition-speed,75ms) ease-out;box-shadow:var(--fallback-box-shadow);appearance:none;background-color:inherit;-webkit-appearance:none;-moz-appearance:none}.input[disabled]{cursor:not-allowed;opacity:var(--field-disabled-opacity,.6)}.input[readonly]{-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all}.input::-moz-placeholder{opacity:.5}.input:-ms-input-placeholder{opacity:.5}.input::placeholder{opacity:.5}.input:hover{box-shadow:var(--field-hover-box-shadow,0 0 0 var(--border-width-default,1px) var(--color-light)),var(--fallback-box-shadow)}.input:focus{box-shadow:var(--field-focus-box-shadow,0 0 0 var(--border-width-2,2px) var(--color-light)),var(--fallback-box-shadow);outline:2px solid transparent;outline-offset:2px}.input{border-radius:var(--border-radius-default,.25rem);border-width:var(--border-width-default,1px);display:inline-flex;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-tight,1.25);max-width:100%;padding:var(--spacing-2,.5rem);width:100%}.upload .button.input{width:auto}.select{position:relative}.select select{display:block;padding-right:var(--spacing-10,2.5rem)}.select .button:focus:not([disabled]):not(select),.select .button:hover:not([disabled]):not(select){filter:brightness(var(--button-filter-brightness,95%))}.select select{border-color:var(--color-light);transition:box-shadow var(--field-transition-speed,75ms) ease-out,border var(--field-transition-speed,75ms) ease-out;box-shadow:var(--fallback-box-shadow);appearance:none;background-color:inherit;-webkit-appearance:none;-moz-appearance:none}.select select[disabled]{cursor:not-allowed;opacity:var(--field-disabled-opacity,.6)}.select select[readonly]{-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all}.select select::-moz-placeholder{opacity:.5}.select select:-ms-input-placeholder{opacity:.5}.select select::placeholder{opacity:.5}.select select:hover{box-shadow:var(--field-hover-box-shadow,0 0 0 var(--border-width-default,1px) var(--color-light)),var(--fallback-box-shadow)}.select select:focus{box-shadow:var(--field-focus-box-shadow,0 0 0 var(--border-width-2,2px) var(--color-light)),var(--fallback-box-shadow);outline:2px solid transparent;outline-offset:2px}.select select{border-radius:var(--border-radius-default,.25rem);border-width:var(--border-width-default,1px);display:inline-flex;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-tight,1.25);max-width:100%;padding:var(--spacing-2,.5rem);width:100%}.select .upload .buttonselect{width:auto}.select:after{right:var(--select-chev-offset,1em);--color-icon:var(--color-core);position:absolute;z-index:10;border:var(--chev-width,1.5px) solid var(--color-icon,var(--color-core));height:var(--chev-size,.5em);width:var(--chev-size,.5em);transform:rotate(45deg);transform-origin:top;top:50%;content:" ";border-top-width:var(--border-width-0,0);border-left-width:var(--border-width-0,0);align-self:center;margin-top:auto;margin-bottom:auto;pointer-events:none;top:0;bottom:0}.switch{cursor:pointer;display:inline-block;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-snug,1.375);padding-right:var(--spacing-2,.5rem);position:relative;vertical-align:baseline}.switch input[disabled],.switch input[disabled]~*{opacity:var(--switch-disabled-opacity,.8);cursor:not-allowed}.switch input{padding-right:var(--spacing-1,.25rem)}.button:focus:not([disabled]):not(.textarea),.button:hover:not([disabled]):not(.textarea){filter:brightness(var(--button-filter-brightness,95%))}.textarea{border-color:var(--color-light);transition:box-shadow var(--field-transition-speed,75ms) ease-out,border var(--field-transition-speed,75ms) ease-out;box-shadow:var(--fallback-box-shadow);appearance:none;background-color:inherit;-webkit-appearance:none;-moz-appearance:none}.textarea[disabled]{cursor:not-allowed;opacity:var(--field-disabled-opacity,.6)}.textarea[readonly]{-webkit-user-select:all;-moz-user-select:all;-ms-user-select:all;user-select:all}.textarea::-moz-placeholder{opacity:.5}.textarea:-ms-input-placeholder{opacity:.5}.textarea::placeholder{opacity:.5}.textarea:hover{box-shadow:var(--field-hover-box-shadow,0 0 0 var(--border-width-default,1px) var(--color-light)),var(--fallback-box-shadow)}.textarea:focus{box-shadow:var(--field-focus-box-shadow,0 0 0 var(--border-width-2,2px) var(--color-light)),var(--fallback-box-shadow);outline:2px solid transparent;outline-offset:2px}.textarea{border-radius:var(--border-radius-default,.25rem);border-width:var(--border-width-default,1px);display:inline-flex;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");line-height:var(--line-height-tight,1.25);max-width:100%;padding:var(--spacing-2,.5rem);width:100%}.upload .button.textarea{width:auto}.upload{cursor:pointer;display:inline-block;font-family:var(--family-secondary,"Inter",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");padding-right:var(--spacing-2,.5rem);position:relative}.upload input{display:none}.upload .button.field{width:auto}:root{text-rendering:var(--text-rendering-mode);min-height:var(--min-height);font-family:var(--family-primary);color:var(--color-content);scroll-behavior:smooth}.\~neutral{--color-content:var(--color-neutral-normal-content);--color-fill:var(--color-neutral-normal-fill);--color-background:var(--color-neutral-normal-background,var(--color-fill));--color-muted:var(--color-neutral-normal-muted);--color-title:var(--color-neutral-normal-title,var(--color-neutral-normal-content));--color-core:var(--color-neutral);--color-light:var(--color-neutral-light,var(--color-core));--color-icon:var(--color-neutral-normal-muted)}.\~neutral.\!low{--color-content:var(--color-neutral-low-content);--color-fill:var(--color-neutral-low-fill);--color-background:var(--color-neutral-low-background,var(--color-fill));--color-muted:var(--color-neutral-low-muted);--color-title:var(--color-neutral-low-title,var(--color-neutral-low-content));--color-icon:var(--color-neutral-low-muted)}.\~neutral.\!normal{--color-content:var(--color-neutral-normal-content);--color-fill:var(--color-neutral-normal-fill);--color-background:var(--color-neutral-normal-background,var(--color-fill));--color-muted:var(--color-neutral-normal-muted);--color-title:var(--color-neutral-normal-title,var(--color-neutral-normal-content));--color-icon:var(--color-neutral-normal-muted)}.\~neutral.\!high{--color-content:var(--color-neutral-high-content);--color-fill:var(--color-neutral-high-fill);--color-background:var(--color-neutral-high-background,var(--color-fill));--color-muted:var(--color-neutral-high-muted);--color-title:var(--color-neutral-high-title,var(--color-neutral-high-content));--color-icon:var(--color-neutral-high-muted)}.\~positive{--color-content:var(--color-positive-normal-content);--color-fill:var(--color-positive-normal-fill);--color-background:var(--color-positive-normal-background,var(--color-fill));--color-muted:var(--color-positive-normal-muted);--color-title:var(--color-positive-normal-title,var(--color-positive-normal-content));--color-core:var(--color-positive);--color-light:var(--color-positive-light,var(--color-core));--color-icon:var(--color-positive-normal-muted)}.\~positive.\!low{--color-content:var(--color-positive-low-content);--color-fill:var(--color-positive-low-fill);--color-background:var(--color-positive-low-background,var(--color-fill));--color-muted:var(--color-positive-low-muted);--color-title:var(--color-positive-low-title,var(--color-positive-low-content));--color-icon:var(--color-positive-low-muted)}.\~positive.\!normal{--color-content:var(--color-positive-normal-content);--color-fill:var(--color-positive-normal-fill);--color-background:var(--color-positive-normal-background,var(--color-fill));--color-muted:var(--color-positive-normal-muted);--color-title:var(--color-positive-normal-title,var(--color-positive-normal-content));--color-icon:var(--color-positive-normal-muted)}.\~positive.\!high{--color-content:var(--color-positive-high-content);--color-fill:var(--color-positive-high-fill);--color-background:var(--color-positive-high-background,var(--color-fill));--color-muted:var(--color-positive-high-muted);--color-title:var(--color-positive-high-title,var(--color-positive-high-content));--color-icon:var(--color-positive-high-muted)}.\~warning{--color-content:var(--color-warning-normal-content);--color-fill:var(--color-warning-normal-fill);--color-background:var(--color-warning-normal-background,var(--color-fill));--color-muted:var(--color-warning-normal-muted);--color-title:var(--color-warning-normal-title,var(--color-warning-normal-content));--color-core:var(--color-warning);--color-light:var(--color-warning-light,var(--color-core));--color-icon:var(--color-warning-normal-muted)}.\~warning.\!low{--color-content:var(--color-warning-low-content);--color-fill:var(--color-warning-low-fill);--color-background:var(--color-warning-low-background,var(--color-fill));--color-muted:var(--color-warning-low-muted);--color-title:var(--color-warning-low-title,var(--color-warning-low-content));--color-icon:var(--color-warning-low-muted)}.\~warning.\!normal{--color-content:var(--color-warning-normal-content);--color-fill:var(--color-warning-normal-fill);--color-background:var(--color-warning-normal-background,var(--color-fill));--color-muted:var(--color-warning-normal-muted);--color-title:var(--color-warning-normal-title,var(--color-warning-normal-content));--color-icon:var(--color-warning-normal-muted)}.\~warning.\!high{--color-content:var(--color-warning-high-content);--color-fill:var(--color-warning-high-fill);--color-background:var(--color-warning-high-background,var(--color-fill));--color-muted:var(--color-warning-high-muted);--color-title:var(--color-warning-high-title,var(--color-warning-high-content));--color-icon:var(--color-warning-high-muted)}.\~critical{--color-content:var(--color-critical-normal-content);--color-fill:var(--color-critical-normal-fill);--color-background:var(--color-critical-normal-background,var(--color-fill));--color-muted:var(--color-critical-normal-muted);--color-title:var(--color-critical-normal-title,var(--color-critical-normal-content));--color-core:var(--color-critical);--color-light:var(--color-critical-light,var(--color-core));--color-icon:var(--color-critical-normal-muted)}.\~critical.\!low{--color-content:var(--color-critical-low-content);--color-fill:var(--color-critical-low-fill);--color-background:var(--color-critical-low-background,var(--color-fill));--color-muted:var(--color-critical-low-muted);--color-title:var(--color-critical-low-title,var(--color-critical-low-content));--color-icon:var(--color-critical-low-muted)}.\~critical.\!normal{--color-content:var(--color-critical-normal-content);--color-fill:var(--color-critical-normal-fill);--color-background:var(--color-critical-normal-background,var(--color-fill));--color-muted:var(--color-critical-normal-muted);--color-title:var(--color-critical-normal-title,var(--color-critical-normal-content));--color-icon:var(--color-critical-normal-muted)}.\~critical.\!high{--color-content:var(--color-critical-high-content);--color-fill:var(--color-critical-high-fill);--color-background:var(--color-critical-high-background,var(--color-fill));--color-muted:var(--color-critical-high-muted);--color-title:var(--color-critical-high-title,var(--color-critical-high-content));--color-icon:var(--color-critical-high-muted)}.\~info{--color-content:var(--color-info-normal-content);--color-fill:var(--color-info-normal-fill);--color-background:var(--color-info-normal-background,var(--color-fill));--color-muted:var(--color-info-normal-muted);--color-title:var(--color-info-normal-title,var(--color-info-normal-content));--color-core:var(--color-info);--color-light:var(--color-info-light,var(--color-core));--color-icon:var(--color-info-normal-muted)}.\~info.\!low{--color-content:var(--color-info-low-content);--color-fill:var(--color-info-low-fill);--color-background:var(--color-info-low-background,var(--color-fill));--color-muted:var(--color-info-low-muted);--color-title:var(--color-info-low-title,var(--color-info-low-content));--color-icon:var(--color-info-low-muted)}.\~info.\!normal{--color-content:var(--color-info-normal-content);--color-fill:var(--color-info-normal-fill);--color-background:var(--color-info-normal-background,var(--color-fill));--color-muted:var(--color-info-normal-muted);--color-title:var(--color-info-normal-title,var(--color-info-normal-content));--color-icon:var(--color-info-normal-muted)}.\~info.\!high{--color-content:var(--color-info-high-content);--color-fill:var(--color-info-high-fill);--color-background:var(--color-info-high-background,var(--color-fill));--color-muted:var(--color-info-high-muted);--color-title:var(--color-info-high-title,var(--color-info-high-content));--color-icon:var(--color-info-high-muted)}.\~urge{--color-content:var(--color-urge-normal-content);--color-fill:var(--color-urge-normal-fill);--color-background:var(--color-urge-normal-background,var(--color-fill));--color-muted:var(--color-urge-normal-muted);--color-title:var(--color-urge-normal-title,var(--color-urge-normal-content));--color-core:var(--color-urge);--color-light:var(--color-urge-light,var(--color-core));--color-icon:var(--color-urge-normal-muted)}.\~urge.\!low{--color-content:var(--color-urge-low-content);--color-fill:var(--color-urge-low-fill);--color-background:var(--color-urge-low-background,var(--color-fill));--color-muted:var(--color-urge-low-muted);--color-title:var(--color-urge-low-title,var(--color-urge-low-content));--color-icon:var(--color-urge-low-muted)}.\~urge.\!normal{--color-content:var(--color-urge-normal-content);--color-fill:var(--color-urge-normal-fill);--color-background:var(--color-urge-normal-background,var(--color-fill));--color-muted:var(--color-urge-normal-muted);--color-title:var(--color-urge-normal-title,var(--color-urge-normal-content));--color-icon:var(--color-urge-normal-muted)}.\~urge.\!high{--color-content:var(--color-urge-high-content);--color-fill:var(--color-urge-high-fill);--color-background:var(--color-urge-high-background,var(--color-fill));--color-muted:var(--color-urge-high-muted);--color-title:var(--color-urge-high-title,var(--color-urge-high-content));--color-icon:var(--color-urge-high-muted)}.\!low{--fallback-box-shadow:var(--fallback-box-shadow-low)}.\!normal{--fallback-box-shadow:var(--fallback-box-shadow-normal)}.\!high{--fallback-box-shadow:var(--fallback-box-shadow-high)}.modal{display:none;position:fixed;z-index:12;top:0;left:0;width:100%;height:100%;overflow:auto;background-color:#0006}.modal-shown{display:block}@keyframes modal-hide{0%{opacity:1}to{opacity:0}}.modal-hiding{animation:modal-hide .2s cubic-bezier(.25,.46,.45,.94)}@keyframes modal-content-show{0%{opacity:0;top:-6rem}to{opacity:1;top:0}}.modal-content{position:relative;margin:10% auto;width:30%}.modal-content.wide{width:60%}.modal-shown .modal-content{animation:modal-content-show .3s cubic-bezier(.25,.46,.45,.94)}@media screen and (max-width: 1000px){.modal-content.wide{width:75%}}@media screen and (max-width: 400px){.modal-content,.modal-content.wide{width:90%}}.modal-close{float:right;color:#aaa;font-weight:normal}.modal-close:hover,.modal-close:focus{filter:brightness(60%)}.dark-theme{--settings-section-button-filter:110%;--color-neutral-900:rgba(255,255,255,.87);--color-neutral-800:rgba(255,255,255,.8);--color-neutral-700:rgba(255,255,255,.73);--color-neutral-600:rgba(255,255,255,.66);--color-neutral-500:rgb(153,153,153);--color-neutral-400:#383838;--color-neutral-300:#303030;--color-neutral-200:#292929;--color-neutral-100:#242424;--color-neutral-50:#202020;--color-neutral-000:#101010;--color-critical-900:#fef2f2;--color-critical-800:#fee2e2;--color-critical-700:#fecaca;--color-critical-600:#fca5a5;--color-critical-500:#f87171;--color-critical-400:#ef4444;--color-critical-300:#dc2626;--color-critical-200:#b91c1c;--color-critical-100:#991b1b;--color-critical-50:#7f1d1d;--color-critical-000:#441313;--color-warning-900:#fffbeb;--color-warning-800:#fef3c7;--color-warning-700:#fde68a;--color-warning-600:#fcd34d;--color-warning-500:#fbbf24;--color-warning-400:#f59e0b;--color-warning-300:#d97706;--color-warning-200:#b45309;--color-warning-100:#92400e;--color-warning-50:#783900;--color-warning-000:#411e01;--color-positive-900:#f0fdf4;--color-positive-800:#dcfce7;--color-positive-700:#bbf7d0;--color-positive-600:#86efac;--color-positive-500:#4ade80;--color-positive-400:#22c55e;--color-positive-300:#16a34a;--color-positive-200:#15803d;--color-positive-100:#166534;--color-positive-50:#14532d;--color-positive-000:#0f2e1b;--color-urge-900:#e0ffff;--color-urge-800:#c0fbff;--color-urge-700:#a0f4ff;--color-urge-600:#80e9ff;--color-urge-500:#60dbfb;--color-urge-400:#40cbf3;--color-urge-300:#20b9e9;--color-urge-200:#00a4dc;--color-urge-100:#0054bc;--color-urge-50:#00169a;--color-urge-000:#050076;--color-info-900:#f5f3ff;--color-info-800:#ede9fe;--color-info-700:#ddd6fe;--color-info-600:#c4b5fd;--color-info-500:#a78bfa;--color-info-400:#8b5cf6;--color-info-300:#7c3aed;--color-info-200:#6d28d9;--color-info-100:#5b21b6;--color-info-50:#4c1d95;--color-info-000:#240e44;--color-neutral-normal-content:#ffffff}.light-only{display:none}.dark-only{display:initial}.tooltip{position:relative;display:inline-block}.tooltip .content{visibility:hidden;max-width:10rem;min-width:6rem;background-color:#0009;color:#fff;padding:.5rem;border-radius:6px;overflow-wrap:break-word;text-align:center;position:absolute;z-index:1;top:-1rem}.tooltip.right .content{left:120%}.tooltip.left .content{right:120%}.tooltip .content.sm{font-size:.8rem}.tooltip:hover .content{visibility:visible}.loader{height:auto;color:#0000}.loader .dot{--diameter:.5rem;--radius:calc(var(--diameter) / 2);--deviation:20%;height:var(--diameter);width:var(--diameter);background-color:var(--color-content);border-radius:50%;position:absolute;left:calc(50% - var(--radius));animation:osc 1s cubic-bezier(.72,.16,.31,.97) infinite}.loader.loader-sm .dot{--deviation:10%}@keyframes osc{25%{left:calc(50% + var(--deviation) - var(--radius))}75%{left:calc(50% - var(--deviation))}0%,50%,to{left:calc(50% - var(--radius))}}:root{--border-width-default:2px;--border-width-2:3px;--border-width-4:5px;--border-width-8:8px}.light-theme{--settings-section-button-filter:90%}.body{background-color:#101010}.page-container{margin:5% 20% 5% 20%}@media (max-width: 1100px){.page-container{margin:2%}}@media screen and (max-width: 750px){:root{font-size:.9rem}.table-responsive table{min-width:660px}}.tab-button{font-size:2rem}.mb-half{margin-bottom:.5rem}.mb-1{margin-bottom:1rem}.mb-2{margin-bottom:2rem}.mt-half{margin-top:.5rem}.mt-1{margin-top:1rem}.ml-1{margin-left:1rem}.ml-half{margin-left:.5rem}.mr-1{margin-right:1rem}.pb-1{padding-bottom:1rem}.pl-1{padding-left:1rem}.al{text-align:left}.ar{text-align:right}.inline-block{display:inline-block}.align-top{align-items:top}.flex{display:flex}.flex-expand{display:flex;justify-content:space-between}.flex-row{display:flex;flex-direction:row}.flex-row-group{display:block;flex-grow:1}.row{display:flex;flex-wrap:wrap}.row.baseline{align-items:baseline}.col{flex:1;margin:.5rem}@media screen and (max-width: 400px){.row{flex-direction:column}.col{flex:45%}}.fr{float:right}.monospace{background-color:inherit}sup.\~critical,.text-critical{color:var(--color-critical-normal-content)}.grey{color:var(--color-neutral-500)}.aside.sm{font-size:.8rem;padding:.8rem}.support.lg{font-size:1rem}.badge.lg{font-size:1rem}.inv-created-users strong,p{padding-left:.5rem;padding-bottom:.2rem}.inv-created-users.empty strong,p{padding:0}.inv{overflow:visible}.inv-table{font-size:.8rem}.inv-profilearea{min-width:20%}.inv-profileselect{min-width:100%}.inv-codearea{max-width:40%;min-width:10rem;display:flex;justify-content:center;align-items:center}.inv-empty .inv-codearea{justify-content:start}.invite-link{text-overflow:ellipsis;overflow:hidden;white-space:nowrap;width:auto}.ellipsis{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.no-pad{padding:0px 0px 0px 0px}.elem-pad>*{margin:var(--spacing-4,1rem)}.icon.clickable{padding:.5rem .6rem}.input{box-sizing:border-box}.button.lg{height:2.5rem}.submit{border:none;outline:none}.full-width{box-sizing:border-box;width:100%}.center{justify-content:center}.no-lp{padding-left:0px}.block{display:block}.focused{display:block}.unfocused{display:none}.rotated{transform:rotate(180deg);-webkit-transition:all .3s cubic-bezier(0,.89,.27,.92);-moz-transition:all .3s cubic-bezier(0,.89,.27,.92);-o-transition:all .3s cubic-bezier(0,.89,.27,.92);transition:all .3s cubic-bezier(0,.89,.27,.92)}.not-rotated{transform:rotate(0deg);-webkit-transition:all .3s cubic-bezier(0,.89,.27,.92);-moz-transition:all .3s cubic-bezier(0,.89,.27,.92);-o-transition:all .3s cubic-bezier(0,.89,.27,.92);transition:all .3s cubic-bezier(0,.89,.27,.92)}.stealth-input{font-size:1rem;padding-top:.1rem;padding-bottom:.1rem;margin-left:.5rem;margin-right:1rem;max-width:75%}.stealth-input-hidden{border-style:none;--fallback-box-shadow:none;--field-hover-box-shadow:none;--field-focus-box-shadow:none;padding-top:.1rem;padding-bottom:.1rem}.settings-section-button{box-sizing:border-box;width:100%;height:2.5rem;background-color:#0000}.settings-section-button:hover,.settings-section-button:focus{box-sizing:border-box;width:100%;height:2.5rem;background-color:var(--color-neutral-normal-fill);filter:brightness(var(--settings-section-button-filter))!important}.settings-section-button.selected{background-color:var(--color-neutral-normal-fill);--buton-filter-brightness:var(--settings-section-button-filter);filter:brightness(var(--settings-section-button-filter))!important}.setting{margin-bottom:.25rem}.textarea{resize:vertical}.overflow{overflow:visible}.overflow-y{overflow-y:visible}select,textarea{color:inherit;border:0 solid var(--color-neutral-300);appearance:none;-webkit-appearance:none;-moz-appearance:none}input{color:inherit;border:0 solid var(--color-neutral-300)}p.top{margin-top:0px}.table-responsive{overflow-x:auto}#notification-box{position:fixed;right:1rem;bottom:1rem;z-index:16}.dropdown{padding-bottom:.5rem;margin-bottom:-.5rem} diff --git a/ts/form.ts b/ts/form.ts index bc2a364..df382ea 100644 --- a/ts/form.ts +++ b/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"]]; diff --git a/views.go b/views.go index d6a316a..39af425 100644 --- a/views.go +++ b/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,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. */ // if app.checkInvite(code, false, "") { - if _, ok := app.storage.invites[code]; ok { - 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, - }) - } else { + 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 = "" + } + 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) {