admin: add manual "Send Password reset" to accounts tab

Only appears with Reset links enabled.
Pressing this sends a PWR link to the users selected.
if one user is selected, or if one of you selected users doesn't have a
method of contact, a link is given to the admin to send to them
manually.
This commit is contained in:
Harvey Tindall 2021-10-13 15:04:22 +01:00
parent 9ae16163bb
commit eeb9b07bce
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
41 changed files with 349 additions and 84 deletions

128
api.go
View File

@ -918,6 +918,74 @@ func (app *appContext) DeleteAnnounceTemplate(gc *gin.Context) {
respondBool(200, false, gc)
}
// @Summary Generate password reset links for a list of users, sending the links to them if possible.
// @Produce json
// @Param AdminPasswordResetDTO body AdminPasswordResetDTO true "List of user IDs"
// @Success 204 {object} boolResponse
// @Success 200 {object} AdminPasswordResetRespDTO
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/password-reset [post]
// @Security Bearer
// @tags Users
func (app *appContext) AdminPasswordReset(gc *gin.Context) {
var req AdminPasswordResetDTO
gc.BindJSON(&req)
if req.Users == nil || len(req.Users) == 0 {
app.debug.Println("Ignoring empty request for PWR")
respondBool(400, false, gc)
return
}
linkCount := 0
var pwr InternalPWR
var err error
resp := AdminPasswordResetRespDTO{}
for _, id := range req.Users {
pwr, err = app.GenInternalReset(id)
if err != nil {
app.err.Printf("Failed to get user from Jellyfin: %v", err)
respondBool(500, false, gc)
return
}
if app.internalPWRs == nil {
app.internalPWRs = map[string]InternalPWR{}
}
app.internalPWRs[pwr.PIN] = pwr
sendAddress := app.getAddressOrName(id)
if sendAddress == "" || len(req.Users) == 1 {
resp.Link, err = app.GenResetLink(pwr.PIN)
linkCount++
if sendAddress == "" {
resp.Manual = true
}
}
if sendAddress != "" {
msg, err := app.email.constructReset(
PasswordReset{
Pin: pwr.PIN,
Username: pwr.Username,
Expiry: pwr.Expiry,
Internal: true,
}, app, false,
)
if err != nil {
app.err.Printf("Failed to construct password reset message for \"%s\": %v", pwr.Username, err)
respondBool(500, false, gc)
return
} else if err := app.sendByID(msg, id); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\": %v", sendAddress, err)
} else {
app.info.Printf("Sent password reset message to \"%s\"", sendAddress)
}
}
}
if resp.Link != "" && linkCount == 1 {
gc.JSON(200, resp)
return
}
respondBool(204, true, gc)
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@ -1505,38 +1573,70 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
gc.JSON(400, validation)
return
}
resp, status, err := app.jf.ResetPassword(req.PIN)
if status != 200 || err != nil || !resp.Success {
app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(status, false, gc)
return
isInternal := false
var userID, username string
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
if time.Now().After(reset.Expiry) {
app.info.Printf("Password reset failed: PIN \"%s\" has expired", reset.PIN)
respondBool(401, false, gc)
delete(app.internalPWRs, req.PIN)
return
}
userID = reset.ID
username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(status, false, gc)
return
}
} else {
resp, status, err := app.jf.ResetPassword(req.PIN)
if status != 200 || err != nil || !resp.Success {
app.err.Printf("Password Reset failed (%d): %v", status, err)
respondBool(status, false, gc)
return
}
if req.Password == "" || len(resp.UsersReset) == 0 {
respondBool(200, false, gc)
return
}
username = resp.UsersReset[0]
}
if req.Password == "" || len(resp.UsersReset) == 0 {
respondBool(200, false, gc)
return
var user mediabrowser.User
var status int
var err error
if isInternal {
user, status, err = app.jf.UserByID(userID, false)
} else {
user, status, err = app.jf.UserByName(username, false)
}
user, status, err := app.jf.UserByName(resp.UsersReset[0], false)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" (%d): %v", resp.UsersReset[0], status, err)
app.err.Printf("Failed to get user \"%s\" (%d): %v", username, status, err)
respondBool(500, false, gc)
return
}
status, err = app.jf.SetPassword(user.ID, req.PIN, req.Password)
prevPassword := req.PIN
if isInternal {
prevPassword = ""
}
status, err = app.jf.SetPassword(user.ID, prevPassword, req.Password)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Failed to change password for \"%s\" (%d): %v", resp.UsersReset[0], status, err)
app.err.Printf("Failed to change password for \"%s\" (%d): %v", username, status, err)
respondBool(500, false, gc)
return
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
// Silently fail for changing ombi passwords
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", resp.UsersReset[0], status, err)
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", username, status, err)
respondBool(200, true, gc)
return
}
ombiUser, status, err := app.getOmbiUser(user.ID)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", resp.UsersReset[0], status, err)
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", username, status, err)
respondBool(200, true, gc)
return
}

View File

@ -514,6 +514,18 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
return email, nil
}
// GenResetLink generates and returns a password reset link.
func (app *appContext) GenResetLink(pin string) (string, error) {
url := app.config.Section("password_resets").Key("url_base").String()
var pinLink string
if url == "" {
return pinLink, fmt.Errorf("disabled as no URL Base provided. Set in Settings > Password Resets.")
}
// Strip /invite from end of this URL, ik it's ugly.
pinLink = fmt.Sprintf("%s/reset?pin=%s", url, pin)
return pinLink, nil
}
func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} {
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("messages").Key("message").String()
@ -544,17 +556,16 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo
} else {
template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": pwr.Username})
template["codeExpiry"] = emailer.lang.PasswordReset.template("codeExpiry", tmpl{"date": d, "time": t, "expiresInMinutes": expiresIn})
url := app.config.Section("password_resets").Key("url_base").String()
if linkResetEnabled {
if url != "" {
pinLink, err := app.GenResetLink(pwr.Pin)
if err == nil {
// Strip /invite form end of this URL, ik its ugly.
template["link_reset"] = true
pinLink := fmt.Sprintf("%s/reset?pin=%s", url, pwr.Pin)
template["pin"] = pinLink
// Only used in html email.
template["pin_code"] = pwr.Pin
} else {
app.info.Println("Password Reset link disabled as no URL Base provided. Set in Settings > Password Resets.")
app.info.Println("Couldn't generate PWR link: %v", err)
template["pin"] = pwr.Pin
}
} else {

4
go.mod
View File

@ -12,6 +12,8 @@ replace github.com/hrfee/jfa-go/logger => ./logger
replace github.com/hrfee/jfa-go/linecache => ./linecache
replace github.com/hrfee/mediabrowser => ../mediabrowser
require (
github.com/bwmarrin/discordgo v0.23.2
github.com/emersion/go-autostart v0.0.0-20210130080809-00ed301c8e9a
@ -38,7 +40,7 @@ require (
github.com/hrfee/jfa-go/linecache v0.0.0-20211003145958-a220ba8dfb58
github.com/hrfee/jfa-go/logger v0.0.0-20211003145958-a220ba8dfb58
github.com/hrfee/jfa-go/ombi v0.0.0-20211003145958-a220ba8dfb58
github.com/hrfee/mediabrowser v0.3.5
github.com/hrfee/mediabrowser v0.0.0-00010101000000-000000000000
github.com/itchyny/timefmt-go v0.1.3
github.com/json-iterator/go v1.1.12 // indirect
github.com/lib/pq v1.10.3 // indirect

7
go.sum
View File

@ -20,6 +20,7 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4=
github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -58,6 +59,7 @@ github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 h1:QthAQCekS1YOeYWS
github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.1.0 h1:U0wCEqseLi2ok1fE6b88gJklzriavPJixZysZPkZd/Y=
github.com/getlantern/systray v1.1.0/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/gzip v0.0.1 h1:ezvKOL6jH+jlzdHNE4h9h8q8uMpDQjyl0NN0Jd7jozc=
github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w=
@ -138,8 +140,6 @@ github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoA
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/hrfee/mediabrowser v0.3.5 h1:bOJlI2HLvw7v0c7mcRw5XDRMUHReQzk5z0EJYRyYjpo=
github.com/hrfee/mediabrowser v0.3.5/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@ -216,6 +216,7 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@ -263,8 +264,10 @@ github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2t
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=

View File

@ -12,6 +12,7 @@
window.ombiEnabled = {{ .ombiEnabled }};
window.usernameEnabled = {{ .username }};
window.langFile = JSON.parse({{ .language }});
window.linkResetEnabled = {{ .linkResetEnabled }};
window.language = "{{ .langName }}";
</script>
{{ template "header.html" . }}
@ -253,6 +254,13 @@
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<div id="modal-send-pwr" class="modal">
<div class="modal-content card ~neutral !normal">
<span class="heading">{{ .strings.sendPWR }}</span>
<p class="content" id="send-pwr-note"></p>
<span class="button ~urge !normal mt-half" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<div id="modal-ombi-defaults" class="modal">
<form class="modal-content card" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiUserDefaults }} <span class="modal-close">&times;</span></span>
@ -567,6 +575,7 @@
<span class="col sm button ~urge !normal center mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col sm button ~warning !normal center mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="col sm button ~positive !normal center mb-half" id="accounts-disable-enable">{{ .strings.disable }}</span>
<span class="col sm button ~info !normal center mb-half" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col sm button ~critical !normal center mb-half" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
</div>

View File

@ -14,9 +14,9 @@
<body class="max-w-full overflow-x-hidden section">
<div id="modal-success" class="modal">
<div class="modal-content card">
<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>
<span class="heading mb-1">{{ if .passwordReset }}{{ .strings.passwordReset }}{{ else }}{{ .strings.successHeader }}{{ end }}</span>
<p class="content mb-1">{{ if .passwordReset }}{{ .strings.youCanLoginPassword }}{{ else }}{{ .successMessage }}{{ end }}</p>
<a class="button ~urge !normal full-width center supra submit" href="{{ .jfLink }}" id="create-success-button">{{ .strings.continue }}</a>
</div>
</div>
<div id="modal-confirmation" class="modal">

View File

@ -57,8 +57,13 @@
"reset": "Reset",
"edit": "Edit",
"donate": "Donate",
"sendPWR": "Send Password Reset",
"contactThrough": "Contact through:",
"extendExpiry": "Extend expiry",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",
"sendPWRValidFor": "The link is valid for 30m.",
"customizeMessages": "Customize Messages",
"customizeMessagesDescription": "If you don't want to use jfa-go's message templates, you can create your own using Markdown.",
"markdownSupported": "Markdown is supported.",

View File

@ -10,6 +10,7 @@
"submit": "Indsend",
"send": "Send",
"success": "Succes",
"continue": "Fortsæt",
"error": "Fejl",
"copy": "Kopiér",
"copied": "Kopiret",

View File

@ -9,6 +9,7 @@
"emailAddress": "E-Mail-Adresse",
"submit": "Absenden",
"success": "Erfolgreich",
"continue": "Weiter",
"error": "Fehler",
"copy": "Kopieren",
"theme": "Thema",

View File

@ -9,6 +9,7 @@
"name": "Όνομα",
"submit": "Καταχώρηση",
"success": "Επιτυχία",
"continue": "Συνέχεια",
"error": "Σφάλμα",
"copy": "Αντιγραφή",
"theme": "Θέμα",

View File

@ -10,6 +10,7 @@
"submit": "Submit",
"send": "Send",
"success": "Success",
"continue": "Continue",
"error": "Error",
"copy": "Copy",
"copied": "Copied",

View File

@ -9,6 +9,7 @@
"name": "Nombre",
"submit": "Enviar",
"success": "Éxito",
"continue": "Continuar",
"error": "Error",
"copy": "Copiar",
"copied": "Copiado",

View File

@ -10,6 +10,7 @@
"submit": "تایید",
"send": "ارسال",
"success": "موفقیت",
"continue": "ادامه دادن",
"error": "خطا",
"copy": "کپی",
"copied": "کپی شد",

View File

@ -10,6 +10,7 @@
"emailAddress": "Addresse Email",
"submit": "Soumettre",
"success": "Succès",
"continue": "Continuer",
"error": "Erreur",
"copy": "Copier",
"time24h": "Temps 24h",

View File

@ -9,6 +9,7 @@
"name": "Nama",
"submit": "Submit",
"success": "Sukses",
"continue": "Lanjut",
"error": "Error",
"copy": "Salin",
"time24h": "Waktu 24 jam",

View File

@ -9,6 +9,7 @@
"emailAddress": "E-mailadres",
"submit": "Verstuur",
"success": "Succes",
"continue": "Doorgaan",
"error": "Fout",
"copy": "Kopiëer",
"theme": "Thema",

View File

@ -9,6 +9,7 @@
"emailAddress": "Endereço de Email",
"submit": "Enviar",
"success": "Sucesso",
"continue": "Continuar",
"error": "Erro",
"copy": "Copiar",
"theme": "Tema",

View File

@ -9,6 +9,7 @@
"name": "Namn",
"submit": "Skicka",
"success": "Lyckades",
"continue": "Fortsätt",
"error": "Fel",
"copy": "Kopiera",
"time24h": "24 timmarsklocka",

View File

@ -10,6 +10,7 @@
"submit": "提交",
"send": "发送",
"success": "成功",
"continue": "继续",
"error": "错误",
"copy": "复制",
"copied": "已复制",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Opret Konto",
"passwordRequirementsHeader": "Adgangskodekrav",
"successHeader": "Succes!",
"successContinueButton": "Fortsæt",
"confirmationRequired": "E-mail bekræftelse er påkrævet",
"confirmationRequiredMessage": "Tjek venligst din e-mail indbakke for at verificere din adresse.",
"yourAccountIsValidUntil": "Din konto er gyldig indtil {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Konto erstellen",
"passwordRequirementsHeader": "Passwortanforderungen",
"successHeader": "Erfolgreich!",
"successContinueButton": "Weiter",
"confirmationRequired": "E-Mail-Bestätigung erforderlich",
"confirmationRequiredMessage": "Bitte überprüfe dein Posteingang und bestätige deine E-Mail-Adresse.",
"yourAccountIsValidUntil": "Dein Konto wird bis zum {date} gültig sein.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Δημιουργία Λογαρισμού",
"passwordRequirementsHeader": "Απαιτήσεις Κωδικού",
"successHeader": "Επιτυχία!",
"successContinueButton": "Συνέχεια",
"confirmationRequired": "Απαιτείται επιβεβαίωση Email",
"confirmationRequiredMessage": "Παρακαλώ ελέγξτε το email σας για να επιβεβαιώσετε την διεύθυνση σας .",
"yourAccountIsValidUntil": "Ο λογαριασμός σου θα ισχύει μέχρι {date}."

View File

@ -14,7 +14,6 @@
"createAccountButton": "Create Account",
"passwordRequirementsHeader": "Password Requirements",
"successHeader": "Success!",
"successContinueButton": "Continue",
"confirmationRequired": "Email confirmation required",
"confirmationRequiredMessage": "Please check your email inbox to verify your address.",
"yourAccountIsValidUntil": "Your account will be valid until {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Crear una cuenta",
"passwordRequirementsHeader": "Requisitos de contraseña",
"successHeader": "¡Éxito!",
"successContinueButton": "Continuar",
"confirmationRequired": "Se requiere confirmación por correo electrónico",
"confirmationRequiredMessage": "Revise la bandeja de entrada de su correo electrónico para verificar su dirección.",
"yourAccountIsValidUntil": "Su cuenta será válida hasta el {date}."

View File

@ -14,7 +14,6 @@
"createAccountButton": "ساخت حساب کاربری",
"passwordRequirementsHeader": "کلمه عبور لازم است",
"successHeader": "موفقیت!",
"successContinueButton": "ادامه دادن",
"confirmationRequired": "تایید ایمیل لازم است",
"confirmationRequiredMessage": "لطفاً برای تأیید آدرس خود ، صندوق پستی ایمیل خود را بررسی کنید.",
"yourAccountIsValidUntil": "حساب شما تا {date} معتبر خواهد بود.",

View File

@ -15,7 +15,6 @@
"createAccountButton": "Créer le compte",
"passwordRequirementsHeader": "Mot de passe requis",
"successHeader": "Succès!",
"successContinueButton": "Continuer",
"confirmationRequired": "Confirmation de l'adresse e-mail requise",
"confirmationRequiredMessage": "Veuillez vérifier votre boite de réception pour confirmer votre adresse e-mail.",
"yourAccountIsValidUntil": "Votre compte sera valide jusqu'au {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Buat Akun",
"passwordRequirementsHeader": "Persyaratan Kata Sandi",
"successHeader": "Sukses!",
"successContinueButton": "Lanjut",
"confirmationRequired": "Konfirmasi email diperlukan",
"confirmationRequiredMessage": "Silakan periksa kotak masuk email Anda untuk memverifikasi alamat Anda."
},

View File

@ -14,7 +14,6 @@
"createAccountButton": "Crea Un Account",
"passwordRequirementsHeader": "Requisiti Password",
"successHeader": "Successo!",
"successContinueButton": "Continua",
"confirmationRequired": "Richiesta la conferma Email",
"confirmationRequiredMessage": "Controlla la tua casella email per verificare il tuo indirizzo."
},

View File

@ -14,7 +14,6 @@
"createAccountButton": "Maak account aan",
"passwordRequirementsHeader": "Wachtwoordvereisten",
"successHeader": "Succes!",
"successContinueButton": "Doorgaan",
"confirmationRequired": "Bevestiging van e-mailadres verplicht",
"confirmationRequiredMessage": "Controleer je e-mail inbox om je adres te bevestigen.",
"yourAccountIsValidUntil": "Je account zal geldig zijn tot {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Criar Conta",
"passwordRequirementsHeader": "Requisitos da Senha",
"successHeader": "Concluído!",
"successContinueButton": "Continuar",
"confirmationRequired": "Confirmação por e-mail",
"confirmationRequiredMessage": "Verifique sua caixa de email para finalizar o cadastro.",
"yourAccountIsValidUntil": "Sua conta é válida até {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "Skapa konto",
"passwordRequirementsHeader": "Lösenordskrav",
"successHeader": "Lyckades!",
"successContinueButton": "Fortsätt",
"confirmationRequired": "E-postbekräftelse krävs",
"confirmationRequiredMessage": "Kontrollera din e-postkorg för att verifiera din adress.",
"yourAccountIsValidUntil": "Ditt konto är giltigt fram tills {date}.",

View File

@ -14,7 +14,6 @@
"createAccountButton": "创建账户",
"passwordRequirementsHeader": "密码格式要求",
"successHeader": "成功!",
"successContinueButton": "继续",
"confirmationRequired": "需要邮件确认",
"confirmationRequiredMessage": "请登录您的邮箱收件箱来验证您的地址。",
"yourAccountIsValidUntil": "您的账户将在 {date} 之前有效。",

View File

@ -9,6 +9,7 @@
"tryAgain": "Please try again.",
"youCanLogin": "You can now log in with the below code as your password.",
"youCanLoginOmbi": "You can now log in to Jellyfin & Ombi with the below code as your password.",
"youCanLoginPassword": "You can now login with your new password. Press below to continue to Jellyfin.",
"changeYourPassword": "Make sure to change your password after you log in.",
"enterYourPassword": "Enter your new password below."
}

View File

@ -108,6 +108,7 @@ type appContext struct {
newUpdate bool // Whether whatever's in update is new.
tag Tag
update Update
internalPWRs map[string]InternalPWR
}
func generateSecret(length int) (string, error) {

View File

@ -1,5 +1,7 @@
package main
import "time"
type stringResponse struct {
Response string `json:"response" example:"message"`
Error string `json:"error" example:"errorDescription"`
@ -320,3 +322,20 @@ type ResetPasswordDTO struct {
PIN string `json:"pin"`
Password string `json:"password"`
}
type AdminPasswordResetDTO struct {
Users []string `json:"users"` // List of Jellyfin user IDs
}
type AdminPasswordResetRespDTO struct {
Link string `json:"link"` // Only returned if one of the given users doesn't have a contact method set, or only one user was requested.
Manual bool `json:"manual"` // Whether or not the admin has to send the link manually or not.
}
// InternalPWR stores a local version of a password reset PIN used for resets triggered by the admin when reset links are enabled.
type InternalPWR struct {
PIN string `json:"pin"`
Username string `json:"username"`
ID string `json:"id"`
Expiry time.Time `json:"expiry"`
}

View File

@ -9,6 +9,22 @@ import (
"github.com/fsnotify/fsnotify"
)
// GenInternalReset generates a local password reset PIN, for use with the PWR option on the Admin page.
func (app *appContext) GenInternalReset(userID string) (InternalPWR, error) {
pin := genAuthToken()
user, status, err := app.jf.UserByID(userID, false)
if err != nil || status != 200 {
return InternalPWR{}, err
}
pwr := InternalPWR{
PIN: pin,
Username: user.Name,
ID: userID,
Expiry: time.Now().Add(30 * time.Minute),
}
return pwr, nil
}
func (app *appContext) StartPWR() {
app.info.Println("Starting password reset daemon")
path := app.config.Section("password_resets").Key("watch_directory").String()
@ -38,6 +54,7 @@ type PasswordReset struct {
Pin string `json:"Pin"`
Username string `json:"UserName"`
Expiry time.Time `json:"ExpirationDate"`
Internal bool `json:"Internal,omitempty"`
}
func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
@ -81,7 +98,7 @@ func pwrMonitor(app *appContext, watcher *fsnotify.Watcher) {
msg, err := app.email.constructReset(pwr, app, false)
if err != nil {
app.err.Printf("Failed to construct password reset message for %s", pwr.Username)
app.err.Printf("Failed to construct password reset message for \"%s\"", pwr.Username)
app.debug.Printf("%s: Error: %s", pwr.Username, err)
} else if err := app.sendByID(msg, uid); err != nil {
app.err.Printf("Failed to send password reset message to \"%s\"", name)

View File

@ -169,6 +169,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/users/announce/:name", app.GetAnnounceTemplate)
api.DELETE(p+"/users/announce/:name", app.DeleteAnnounceTemplate)
api.POST(p+"/users/password-reset", app.AdminPasswordReset)
api.GET(p+"/config/update", app.CheckUpdate)
api.POST(p+"/config/update", app.ApplyUpdate)
api.GET(p+"/config/emails", app.GetCustomEmails)

View File

@ -72,6 +72,10 @@ window.availableProfiles = window.availableProfiles || [];
if (window.discordEnabled) {
window.modals.discord = new Modal(document.getElementById("modal-discord"));
}
if (window.linkResetEnabled) {
window.modals.sendPWR = new Modal(document.getElementById("modal-send-pwr"));
}
})();
var inviteCreator = new createInvite();

View File

@ -1,4 +1,4 @@
import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText } from "../modules/common.js";
import { _get, _post, _delete, toggleLoader, addLoader, removeLoader, toDateString, insertText, toClipboard } from "../modules/common.js";
import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
@ -63,7 +63,7 @@ class user implements User {
id = "";
private _selected: boolean;
private _lastNotifyMethod = (): string => {
lastNotifyMethod = (): string => {
// Telegram, Matrix, Discord
const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != "";
const discord = window.discordEnabled && this._discordUsername && this._discordUsername != "";
@ -188,7 +188,7 @@ class user implements User {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "matrix";
const lastNotifyMethod = this.lastNotifyMethod() == "matrix";
this._matrixID = u;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.add("unfocused");
@ -253,7 +253,7 @@ class user implements User {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "telegram";
const lastNotifyMethod = this.lastNotifyMethod() == "telegram";
this._telegramUsername = u;
if (!u) {
this._notifyDropdown.querySelector(".accounts-area-telegram").classList.add("unfocused");
@ -319,7 +319,7 @@ class user implements User {
this._notifyDropdown.querySelector(".accounts-area-discord").classList.add("unfocused");
return;
}
const lastNotifyMethod = this._lastNotifyMethod() == "discord";
const lastNotifyMethod = this.lastNotifyMethod() == "discord";
this._discordUsername = u;
if (!u) {
this._discord.innerHTML = `<span class="chip btn !low">Add</span>`;
@ -566,6 +566,7 @@ export class accountsList {
private _modifySettings = document.getElementById("accounts-modify-user") as HTMLSpanElement;
private _modifySettingsProfile = document.getElementById("radio-use-profile") as HTMLInputElement;
private _modifySettingsUser = document.getElementById("radio-use-user") as HTMLInputElement;
private _sendPWR = document.getElementById("accounts-send-pwr") as HTMLSpanElement;
private _profileSelect = document.getElementById("modify-user-profiles") as HTMLSelectElement;
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _search = document.getElementById("accounts-search") as HTMLInputElement;
@ -698,6 +699,7 @@ export class accountsList {
}
this._extendExpiry.classList.add("unfocused");
this._disableEnable.classList.add("unfocused");
this._sendPWR.classList.add("unfocused");
} else {
let visibleCount = 0;
for (let id in this._users) {
@ -719,6 +721,7 @@ export class accountsList {
this._announceButton.classList.remove("unfocused");
}
let anyNonExpiries = list.length == 0 ? true : false;
let noContactCount = 0;
// Only show enable/disable button if all selected have the same state.
this._shouldEnable = this._users[list[0]].disabled
let showDisableEnable = true;
@ -732,10 +735,19 @@ export class accountsList {
this._disableEnable.classList.add("unfocused");
}
if (!showDisableEnable && anyNonExpiries) { break; }
if (!this._users[id].lastNotifyMethod() && !this._users[id].email) {
noContactCount++;
}
}
if (!anyNonExpiries) {
this._extendExpiry.classList.remove("unfocused");
}
// Only show "Send PWR" if a maximum of 1 user selected doesn't have a contact method
if (noContactCount > 1) {
this._sendPWR.classList.add("unfocused");
} else {
this._sendPWR.classList.remove("unfocused");
}
if (showDisableEnable) {
let message: string;
if (this._shouldEnable) {
@ -1042,6 +1054,58 @@ export class accountsList {
};
window.modals.deleteUser.show();
}
sendPWR = () => {
addLoader(this._sendPWR);
let list = this._collectUsers();
let manualUser: user;
for (let id of list) {
let user = this._users[id];
console.log(user, user.notify_email, user.notify_matrix, user.notify_discord, user.notify_telegram);
if (!user.lastNotifyMethod() && !user.email) {
manualUser = user;
break;
}
}
const messageBox = document.getElementById("send-pwr-note") as HTMLParagraphElement;
let message: string;
let send = {
users: list
};
_post("/users/password-reset", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
removeLoader(this._sendPWR);
let link: string;
if (req.status == 200) {
link = req.response["link"];
if (req.response["manual"] as boolean) {
message = window.lang.var("strings", "sendPWRManual", manualUser.name);
} else {
message = window.lang.strings("sendPWRSuccess") + " " + window.lang.strings("sendPWRSuccessManual");
}
} else if (req.status == 204) {
message = window.lang.strings("sendPWRSuccess");
} else {
window.notifications.customError("errorSendPWR", window.lang.strings("errorFailureCheckLogs"));
return;
}
message += " " + window.lang.strings("sendPWRValidFor");
messageBox.textContent = message;
let linkButton = document.getElementById("send-pwr-link") as HTMLSpanElement;
linkButton.onclick = () => {
toClipboard(link);
linkButton.textContent = window.lang.strings("copied");
linkButton.classList.add("~positive");
linkButton.classList.remove("~urge");
setTimeout(() => {
linkButton.textContent = window.lang.strings("copy");
linkButton.classList.add("~urge");
linkButton.classList.remove("~positive");
}, 800);
};
window.modals.sendPWR.show();
}, true);
}
modifyUsers = () => {
const modalHeader = document.getElementById("header-modify-user");
@ -1203,6 +1267,12 @@ export class accountsList {
this._addUserName.classList.add("unfocused");
this._addUserName = this._addUserEmail;
}
if (!window.linkResetEnabled) {
this._sendPWR.classList.add("unfocused");
} else {
this._sendPWR.onclick = this.sendPWR;
}
/*if (!window.emailEnabled) {
this._deleteNotify.parentElement.classList.add("unfocused");
this._deleteNotify.checked = false;

View File

@ -25,6 +25,7 @@ declare interface Window {
matrixEnabled: boolean;
ombiEnabled: boolean;
usernameEnabled: boolean;
linkResetEnabled: boolean;
token: string;
buttonWidth: number;
transitionEvent: string;
@ -105,6 +106,7 @@ declare interface Modals {
telegram: Modal;
discord: Modal;
matrix: Modal;
sendPWR?: Modal;
}
interface Invite {

102
views.go
View File

@ -116,23 +116,24 @@ func (app *appContext) AdminPage(gc *gin.Context) {
}
license = string(l)
gcHTML(gc, http.StatusOK, "admin.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"contactMessage": "",
"emailEnabled": emailEnabled,
"telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
"ombiEnabled": ombiEnabled,
"notifications": notificationsEnabled,
"version": version,
"commit": commit,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Admin[lang].Strings,
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"language": app.storage.lang.Admin[lang].JSON,
"langName": lang,
"license": license,
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"contactMessage": "",
"emailEnabled": emailEnabled,
"telegramEnabled": telegramEnabled,
"discordEnabled": discordEnabled,
"matrixEnabled": matrixEnabled,
"ombiEnabled": ombiEnabled,
"linkResetEnabled": app.config.Section("password_resets").Key("link_reset").MustBool(false),
"notifications": notificationsEnabled,
"version": version,
"commit": commit,
"username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.Admin[lang].Strings,
"quantityStrings": app.storage.lang.Admin[lang].QuantityStrings,
"language": app.storage.lang.Admin[lang].JSON,
"langName": lang,
"license": license,
})
}
@ -154,6 +155,11 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
"success": false,
"ombiEnabled": app.config.Section("ombi").Key("enabled").MustBool(false),
}
if _, ok := app.internalPWRs[pin]; !ok {
app.debug.Printf("Ignoring PWR request due to invalid internal PIN: %s", pin)
app.NoRouteHandler(gc)
return
}
if setPassword {
data["helpMessage"] = app.config.Section("ui").Key("help_message").String()
data["successMessage"] = app.config.Section("ui").Key("success_message").String()
@ -177,33 +183,47 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
app.debug.Println("PWR: Ignoring magic link visit from bot")
data["success"] = true
data["pin"] = "NO-BO-TS"
return
}
// if reset, ok := app.internalPWRs[pin]; ok {
// status, err := app.jf.ResetPasswordAdmin(reset.ID)
// if !(status == 200 || status == 204) || err != nil {
// app.err.Printf("Password Reset failed (%d): %v", status, err)
// return
// }
// status, err = app.jf.SetPassword(reset.ID, "", pin)
// if !(status == 200 || status == 204) || err != nil {
// app.err.Printf("Password Reset failed (%d): %v", status, err)
// return
// }
// data["success"] = true
// data["pin"] = pin
// }
resp, status, err := app.jf.ResetPassword(pin)
if status == 200 && err == nil && resp.Success {
data["success"] = true
data["pin"] = pin
} else {
resp, status, err := app.jf.ResetPassword(pin)
if status == 200 && err == nil && resp.Success {
data["success"] = true
data["pin"] = pin
} else {
app.err.Printf("Password Reset failed (%d): %v", status, err)
app.err.Printf("Password Reset failed (%d): %v", status, err)
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(resp.UsersReset[0], false)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", resp.UsersReset[0], status, err)
return
}
if app.config.Section("ombi").Key("enabled").MustBool(false) {
jfUser, status, err := app.jf.UserByName(resp.UsersReset[0], false)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from jellyfin/emby (%d): %v", resp.UsersReset[0], status, err)
return
}
ombiUser, status, err := app.getOmbiUser(jfUser.ID)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", resp.UsersReset[0], status, err)
return
}
ombiUser["password"] = pin
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
ombiUser, status, err := app.getOmbiUser(jfUser.ID)
if status != 200 || err != nil {
app.err.Printf("Failed to get user \"%s\" from ombi (%d): %v", resp.UsersReset[0], status, err)
return
}
ombiUser["password"] = pin
status, err = app.ombi.ModifyUser(ombiUser)
if status != 200 || err != nil {
app.err.Printf("Failed to set password for ombi user \"%s\" (%d): %v", ombiUser["userName"], status, err)
return
}
app.debug.Printf("Reset password for ombi user \"%s\"", ombiUser["userName"])
}
}