1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-06-26 11:27:46 +02:00

Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
643eb0086a
Merge 97506c0bcd into 7f518f55b2 2023-12-22 05:14:10 -07:00
52 changed files with 582 additions and 1012 deletions

View File

@ -45,6 +45,9 @@ type: docker
steps: steps:
- name: build-deploy - name: build-deploy
image: appleboy/drone-ssh image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
settings: settings:
host: host:
from_secret: ssh2_host from_secret: ssh2_host
@ -52,8 +55,9 @@ steps:
from_secret: ssh2_username from_secret: ssh2_username
port: port:
from_secret: ssh2_port from_secret: ssh2_port
key: volumes:
from_secret: ssh2_key - /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
command_timeout: 50m command_timeout: 50m
script: script:
- /mnt/buildx/jfa-go/build.sh stable - /mnt/buildx/jfa-go/build.sh stable
@ -124,6 +128,9 @@ type: docker
steps: steps:
- name: build-deploy - name: build-deploy
image: appleboy/drone-ssh image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
environment: environment:
BUILDRONE_KEY: BUILDRONE_KEY:
from_secret: BUILDRONE_KEY from_secret: BUILDRONE_KEY
@ -134,10 +141,11 @@ steps:
from_secret: ssh2_username from_secret: ssh2_username
port: port:
from_secret: ssh2_port from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs: envs:
- buildrone_key - buildrone_key
key: key_path: /root/drone_rsa
from_secret: ssh2_key
command_timeout: 50m command_timeout: 50m
script: script:
- /mnt/buildx/jfa-go/build.sh - /mnt/buildx/jfa-go/build.sh

View File

@ -13,7 +13,7 @@
Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all). Studies mean I can't work on this project a lot outside of breaks, however I hope i'll be able to fit in general support and things like bug fixes into my time. New features and such will likely come in short bursts throughout the year (if they do at all).
#### Does/Will it still work? #### Does/Will it still work?
jfa-go currently works on Jellyfin 10.8.13, the latest version as of 26/12/23. I should be able to maintain compatability in the future, unless any big changes occur. jfa-go currently works on Jellyfin 10.8.9, the latest version. I should be able to maintain compatability in the future, unless any big changes occur.
#### Alternatives #### Alternatives
If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself. If you want a bit more of a guarantee of support, I've seen these projects mentioned although haven't tried them myself.

View File

@ -138,7 +138,6 @@ func (app *appContext) GetActivities(gc *gin.Context) {
InviteCode: act.InviteCode, InviteCode: act.InviteCode,
Value: act.Value, Value: act.Value,
Time: act.Time.Unix(), Time: act.Time.Unix(),
IP: act.IP,
} }
if act.Type == ActivityDeletion || act.Type == ActivityCreation { if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value resp.Activities[i].Username = act.Value

View File

@ -102,7 +102,7 @@ func (app *appContext) checkInvites() {
InviteCode: data.Code, InviteCode: data.Code,
Value: data.Label, Value: data.Label,
Time: time.Now(), Time: time.Now(),
}, nil, false) })
} }
} }
@ -161,7 +161,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code, InviteCode: code,
Value: inv.Label, Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}, nil, false) })
} else if used { } else if used {
del := false del := false
newInv := inv newInv := inv
@ -174,7 +174,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
InviteCode: code, InviteCode: code,
Value: inv.Label, Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}, nil, false) })
} else if newInv.RemainingUses != 0 { } else if newInv.RemainingUses != 0 {
// 0 means infinite i guess? // 0 means infinite i guess?
newInv.RemainingUses-- newInv.RemainingUses--
@ -285,7 +285,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
InviteCode: invite.Code, InviteCode: invite.Code,
Value: invite.Label, Value: invite.Label,
Time: time.Now(), Time: time.Now(),
}, gc, false) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -305,8 +305,7 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.IsReferral { if inv.IsReferral {
continue continue
} }
years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) _, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime)
months += years * 12
invite := inviteDTO{ invite := inviteDTO{
Code: inv.Code, Code: inv.Code,
Months: months, Months: months,
@ -493,7 +492,7 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
InviteCode: req.Code, InviteCode: req.Code,
Value: inv.Label, Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}, gc, false) })
app.info.Printf("%s: Invite deleted", req.Code) app.info.Printf("%s: Invite deleted", req.Code)
respondBool(200, true, gc) respondBool(200, true, gc)

View File

@ -573,7 +573,6 @@ func (app *appContext) MatrixCheckPIN(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password." // @Param MatrixLoginDTO body MatrixLoginDTO true "Username & password."
// @Router /matrix/login [post] // @Router /matrix/login [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) MatrixLogin(gc *gin.Context) { func (app *appContext) MatrixLogin(gc *gin.Context) {
var req MatrixLoginDTO var req MatrixLoginDTO
@ -609,7 +608,6 @@ func (app *appContext) MatrixLogin(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID." // @Param MatrixConnectUserDTO body MatrixConnectUserDTO true "User's Jellyfin ID & Matrix user ID."
// @Router /users/matrix [post] // @Router /users/matrix [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) MatrixConnect(gc *gin.Context) { func (app *appContext) MatrixConnect(gc *gin.Context) {
var req MatrixConnectUserDTO var req MatrixConnectUserDTO
@ -641,7 +639,6 @@ func (app *appContext) MatrixConnect(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param username path string true "username to search." // @Param username path string true "username to search."
// @Router /users/discord/{username} [get] // @Router /users/discord/{username} [get]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) DiscordGetUsers(gc *gin.Context) { func (app *appContext) DiscordGetUsers(gc *gin.Context) {
name := gc.Param("username") name := gc.Param("username")
@ -668,7 +665,6 @@ func (app *appContext) DiscordGetUsers(gc *gin.Context) {
// @Failure 500 {object} boolResponse // @Failure 500 {object} boolResponse
// @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID." // @Param DiscordConnectUserDTO body DiscordConnectUserDTO true "User's Jellyfin ID & Discord ID."
// @Router /users/discord [post] // @Router /users/discord [post]
// @Security Bearer
// @tags Other // @tags Other
func (app *appContext) DiscordConnect(gc *gin.Context) { func (app *appContext) DiscordConnect(gc *gin.Context) {
var req DiscordConnectUserDTO var req DiscordConnectUserDTO
@ -692,7 +688,7 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "discord", Value: "discord",
Time: time.Now(), Time: time.Now(),
}, gc, false) })
linkExistingOmbiDiscordTelegram(app) linkExistingOmbiDiscordTelegram(app)
respondBool(200, true, gc) respondBool(200, true, gc)
@ -703,7 +699,6 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/discord [delete] // @Router /users/discord [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkDiscord(gc *gin.Context) { func (app *appContext) UnlinkDiscord(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -722,7 +717,7 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "discord", Value: "discord",
Time: time.Now(), Time: time.Now(),
}, gc, false) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -732,7 +727,6 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/telegram [delete] // @Router /users/telegram [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkTelegram(gc *gin.Context) { func (app *appContext) UnlinkTelegram(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -751,7 +745,7 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "telegram", Value: "telegram",
Time: time.Now(), Time: time.Now(),
}, gc, false) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -761,7 +755,6 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
// @Success 200 {object} boolResponse // @Success 200 {object} boolResponse
// @Param forUserDTO body forUserDTO true "User's Jellyfin ID." // @Param forUserDTO body forUserDTO true "User's Jellyfin ID."
// @Router /users/matrix [delete] // @Router /users/matrix [delete]
// @Security Bearer
// @Tags Users // @Tags Users
func (app *appContext) UnlinkMatrix(gc *gin.Context) { func (app *appContext) UnlinkMatrix(gc *gin.Context) {
var req forUserDTO var req forUserDTO
@ -780,7 +773,7 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "matrix", Value: "matrix",
Time: time.Now(), Time: time.Now(),
}, gc, false) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }

View File

@ -216,7 +216,7 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "email", Value: "email",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
ombiUser, code, err := app.getOmbiUser(id) ombiUser, code, err := app.getOmbiUser(id)
@ -378,7 +378,7 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "discord", Value: "discord",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -426,7 +426,7 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "telegram", Value: "telegram",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -507,7 +507,7 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "matrix", Value: "matrix",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
delete(app.matrix.tokens, pin) delete(app.matrix.tokens, pin)
respondBool(200, true, gc) respondBool(200, true, gc)
@ -529,7 +529,7 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "discord", Value: "discord",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -550,7 +550,7 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "telegram", Value: "telegram",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -571,7 +571,7 @@ func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "matrix", Value: "matrix",
Time: time.Now(), Time: time.Now(),
}, gc, true) })
respondBool(200, true, gc) respondBool(200, true, gc)
} }
@ -701,7 +701,7 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
SourceType: ActivityUser, SourceType: ActivityUser,
Source: user.ID, Source: user.ID,
Time: time.Now(), Time: time.Now(),
}, gc, true) })
if app.config.Section("ombi").Key("enabled").MustBool(false) { if app.config.Section("ombi").Key("enabled").MustBool(false) {
func() { func() {

View File

@ -55,7 +55,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: user.Name, Value: user.Name,
Time: time.Now(), Time: time.Now(),
}, gc, false) })
profile := app.storage.GetDefaultProfile() profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" { if req.Profile != "" && req.Profile != "none" {
@ -114,7 +114,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
type errorFunc func(gc *gin.Context) type errorFunc func(gc *gin.Context)
// Used on the form & when a users email has been confirmed. // Used on the form & when a users email has been confirmed.
func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context) (f errorFunc, success bool) { func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, success bool) {
existingUser, _, _ := app.jf.UserByName(req.Username, false) existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser.Name != "" { if existingUser.Name != "" {
f = func(gc *gin.Context) { f = func(gc *gin.Context) {
@ -331,7 +331,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
InviteCode: invite.Code, InviteCode: invite.Code,
Value: user.Name, Value: user.Name,
Time: time.Now(), Time: time.Now(),
}, gc, true) })
emailStore := EmailAddress{ emailStore := EmailAddress{
Addr: req.Email, Addr: req.Email,
@ -503,7 +503,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO var req newUserDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: New user attempt", req.Code) app.debug.Printf("%s: New user attempt", req.Code)
if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText, false) { if app.config.Section("captcha").Key("enabled").MustBool(false) && !app.verifyCaptcha(req.Code, req.CaptchaID, req.CaptchaText) {
app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code) app.info.Printf("%s: New user failed: Captcha Incorrect", req.Code)
respond(400, "errorCaptcha", gc) respond(400, "errorCaptcha", gc)
return return
@ -539,7 +539,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
return return
} }
} }
f, success := app.newUser(req, false, gc) f, success := app.newUser(req, false)
if !success { if !success {
f(gc) f(gc)
return return
@ -609,7 +609,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Time: time.Now(), Time: time.Now(),
}, gc, false) })
if sendMail && req.Notify { if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil { if err := app.sendByID(msg, userID); err != nil {
@ -687,7 +687,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: username, Value: username,
Time: time.Now(), Time: time.Now(),
}, gc, false) })
if sendMail && req.Notify { if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil { if err := app.sendByID(msg, userID); err != nil {
@ -1208,7 +1208,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: "email", Value: "email",
Time: time.Now(), Time: time.Now(),
}, gc, false) })
if ombiEnabled { if ombiEnabled {
ombiUser, code, err := app.getOmbiUser(id) ombiUser, code, err := app.getOmbiUser(id)

12
api.go
View File

@ -114,7 +114,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
var req ResetPasswordDTO var req ResetPasswordDTO
gc.BindJSON(&req) gc.BindJSON(&req)
validation := app.validator.validate(req.Password) validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true valid := true
for _, val := range validation { for _, val := range validation {
if !val { if !val {
@ -122,18 +121,12 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
} }
if !valid || req.PIN == "" { if !valid || req.PIN == "" {
// 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN) app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
gc.JSON(400, validation) gc.JSON(400, validation)
return return
} }
isInternal := false isInternal := false
if captcha && !app.verifyCaptcha(req.PIN, req.PIN, req.CaptchaText, true) {
app.info.Printf("%s: PWR Failed: Captcha Incorrect", req.PIN)
respond(400, "errorCaptcha", gc)
return
}
var userID, username string var userID, username string
if reset, ok := app.internalPWRs[req.PIN]; ok { if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true isInternal = true
@ -145,7 +138,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
} }
userID = reset.ID userID = reset.ID
username = reset.Username username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID) status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err) app.err.Printf("Password Reset failed (%d): %v", status, err)
@ -187,7 +179,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
SourceType: ActivityUser, SourceType: ActivityUser,
Source: user.ID, Source: user.ID,
Time: time.Now(), Time: time.Now(),
}, gc, true) })
prevPassword := req.PIN prevPassword := req.PIN
if isInternal { if isInternal {

45
auth.go
View File

@ -18,25 +18,6 @@ const (
REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24 REFRESH_TOKEN_VALIDITY_SEC = 3600 * 24
) )
func (app *appContext) logIpInfo(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.info.Println(out)
}
func (app *appContext) logIpDebug(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.debug.Println(out)
}
func (app *appContext) logIpErr(gc *gin.Context, user bool, out string) {
if (user && LOGIPU) || (!user && LOGIP) {
out += fmt.Sprintf(" (ip=%s)", gc.ClientIP())
}
app.err.Println(out)
}
func (app *appContext) webAuth() gin.HandlerFunc { func (app *appContext) webAuth() gin.HandlerFunc {
return app.authenticate return app.authenticate
} }
@ -152,7 +133,7 @@ type getTokenDTO struct {
Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else. Token string `json:"token" example:"kjsdklsfdkljfsjsdfklsdfkldsfjdfskjsdfjklsdf"` // API token for use with everything else.
} }
func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool) (username, password string, ok bool) { func (app *appContext) decodeValidateLoginHeader(gc *gin.Context) (username, password string, ok bool) {
header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2) header := strings.SplitN(gc.Request.Header.Get("Authorization"), " ", 2)
auth, _ := base64.StdEncoding.DecodeString(header[1]) auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2) creds := strings.SplitN(string(auth), ":", 2)
@ -160,7 +141,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1] password = creds[1]
ok = false ok = false
if username == "" || password == "" { if username == "" || password == "" {
app.logIpDebug(gc, userpage, "Auth denied: blank username/password") app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
@ -168,17 +149,17 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
return return
} }
func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context, userpage bool) (user mediabrowser.User, ok bool) { func (app *appContext) validateJellyfinCredentials(username, password string, gc *gin.Context) (user mediabrowser.User, ok bool) {
ok = false ok = false
user, status, err := app.authJf.Authenticate(username, password) user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil { if status != 200 || err != nil {
if status == 401 || status == 400 { if status == 401 || status == 400 {
app.logIpInfo(gc, userpage, "Auth denied: Invalid username/password (Jellyfin)") app.info.Println("Auth denied: Invalid username/password (Jellyfin)")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
if status == 403 { if status == 403 {
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled") app.info.Println("Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc) respond(403, "yourAccountWasDisabled", gc)
return return
} }
@ -199,8 +180,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth // @tags Auth
// @Security getTokenAuth // @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) { func (app *appContext) getTokenLogin(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (login attempt)") app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, false) username, password, ok := app.decodeValidateLoginHeader(gc)
if !ok { if !ok {
return return
} }
@ -215,12 +196,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
} }
} }
if !app.jellyfinLogin && !match { if !app.jellyfinLogin && !match {
app.logIpInfo(gc, false, "Auth denied: Invalid username/password") app.info.Println("Auth denied: Invalid username/password")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
if !match { if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc, false) user, ok := app.validateJellyfinCredentials(username, password, gc)
if !ok { if !ok {
return return
} }
@ -252,8 +233,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
host := gc.Request.URL.Hostname() gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{token}) gc.JSON(200, getTokenDTO{token})
} }
@ -304,7 +284,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get] // @Router /token/refresh [get]
// @tags Auth // @tags Auth
func (app *appContext) getTokenRefresh(gc *gin.Context) { func (app *appContext) getTokenRefresh(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (refresh token)") app.debug.Println("Token requested (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "refresh") claims, ok := app.decodeValidateRefreshCookie(gc, "refresh")
if !ok { if !ok {
return return
@ -317,7 +297,6 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(500, "Couldn't generate token", gc) respond(500, "Couldn't generate token", gc)
return return
} }
host := gc.Request.URL.Hostname() gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.JSON(200, getTokenDTO{jwt}) gc.JSON(200, getTokenDTO{jwt})
} }

View File

@ -120,9 +120,6 @@ func (app *appContext) loadConfig() error {
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device").SetValue("jfa-go")
app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit)) app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", version, commit))
LOGIP = app.config.Section("advanced").Key("log_ips").MustBool(false)
LOGIPU = app.config.Section("advanced").Key("log_ips_users").MustBool(false)
// These two settings are pretty much the same // These two settings are pretty much the same
url1 := app.config.Section("invite_emails").Key("url_base").String() url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String() url2 := app.config.Section("password_resets").Key("url_base").String()

View File

@ -297,29 +297,6 @@
"advanced": true "advanced": true
}, },
"settings": { "settings": {
"log_ips": {
"name": "Log IPs accessing Admin Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of admins and admin page requests in console and in activities. See notice below on legality."
},
"log_ips_users": {
"name": "Log IPs accessing User Page",
"required": false,
"requires_restart": true,
"type": "bool",
"value": false,
"description": "Log IP addresses of users in console and in activities. See notice below on legality."
},
"ip_note": {
"name": "Logging IPs:",
"type": "note",
"value": "",
"required": "false",
"description": "Logging IP addresses through jfa-go may violate GDPR or other privacy regulations, as IPs are linked to account information. Enable at your own risk."
},
"tls": { "tls": {
"name": "TLS/HTTP2", "name": "TLS/HTTP2",
"required": false, "required": false,

View File

@ -106,6 +106,48 @@ div.card:contains(section.banner.footer) {
padding-bottom: 0px; padding-bottom: 0px;
} }
.tab-button {
font-size: 2rem;
}
.al {
text-align: left;
}
.ar {
text-align: right;
}
.ac {
text-align: center;
}
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}
.align-top {
align-items: top;
}
.flex-expand {
display: flex;
justify-content: space-between;
}
.flex-row-group {
display: block;
flex-grow: 1;
}
.row { .row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -130,7 +172,23 @@ span.sm:not(.heading) {
margin: .25rem; margin: .25rem;
} }
/* Who knows for half of these to be honest */ .flex-col {
display: flex;
flex-direction: column;
}
.flex-form {
display: flex;
flex-direction: column;
}
@media screen and (min-width: 768px) {
.flex-form {
flex: 1;
margin: 0.5rem;
}
}
@media screen and (max-width: 400px) { @media screen and (max-width: 400px) {
.row { .row {
flex-direction: column; flex-direction: column;
@ -161,6 +219,69 @@ sup.\~critical, .text-critical {
font-size: 1rem; font-size: 1rem;
} }
.inv-created-users strong,p {
padding-left: 0.5rem;
padding-bottom: 0.2rem;
}
.inv-created-users.empty strong,p {
padding: 0;
}
.inv {
overflow: visible;
}
.inv-table {
font-size: 0.8rem;
}
.inv-profilearea {
min-width: 20%;
}
.inv-profileselect {
min-width: 100%;
}
.inv-codearea {
max-width: 40%;
min-width: 10rem;
display: flex;
justify-content: start;
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: 0.5rem 0.6rem;
}
.input { .input {
box-sizing: border-box; /* fixes weird length issue with inputs */ box-sizing: border-box; /* fixes weird length issue with inputs */
} }
@ -179,6 +300,10 @@ sup.\~critical, .text-critical {
width: 100%; width: 100%;
} }
.flex-auto {
flex: auto;
}
.center { .center {
justify-content: center; justify-content: center;
} }
@ -187,6 +312,14 @@ sup.\~critical, .text-critical {
align-items: center; align-items: center;
} }
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused { .focused {
display: block; display: block;
} }
@ -283,16 +416,7 @@ table {
color: var(--color-content); color: var(--color-content);
} }
table.table.manual-pad th, table.table.manual-pad td {
padding: 0;
}
table.table-p-0 th, table.table-p-0 td {
padding-left: 0 !important;
padding-right: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
p.top { p.top {
margin-top: 0px; margin-top: 0px;
@ -451,6 +575,7 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer; cursor: pointer;
} }
.g-recaptcha { .g-recaptcha {
overflow: hidden; overflow: hidden;
width: 296px; width: 296px;
@ -462,8 +587,3 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe { .g-recaptcha iframe {
margin: -2px 0px 0px -4px; margin: -2px 0px 0px -4px;
} }
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}

View File

@ -5,7 +5,6 @@
.tooltip .content { .tooltip .content {
visibility: hidden; visibility: hidden;
opacity: 0;
max-width: 10rem; max-width: 10rem;
min-width: 6rem; min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
@ -14,23 +13,12 @@
border-radius: 6px; border-radius: 6px;
overflow-wrap: break-word; overflow-wrap: break-word;
text-align: center; text-align: center;
transition: opacity 100ms;
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: -1rem; top: -1rem;
} }
.tooltip.below .content {
top: 2.5rem;
left: 0;
right: 0;
}
.tooltip.darker .content {
background-color: rgba(0, 0, 0, 0.8);
}
.tooltip.right .content { .tooltip.right .content {
left: 120%; left: 120%;
} }
@ -43,10 +31,6 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.tooltip:hover .content, .tooltip:hover .content {
.tooltip:focus .content,
.tooltip:focus-within .content
{
visibility: visible; visibility: visible;
opacity: 1;
} }

View File

@ -74,17 +74,6 @@ func (app *appContext) clearTelegram() {
} }
} }
func (app *appContext) clearPWRCaptchas() {
app.debug.Println("Housekeeping: Clearing old PWR Captchas")
captchas := map[string]Captcha{}
for k, capt := range app.pwrCaptchas {
if capt.Generated.Add(CAPTCHA_VALIDITY * time.Second).After(time.Now()) {
captchas[k] = capt
}
}
app.pwrCaptchas = captchas
}
func (app *appContext) clearActivities() { func (app *appContext) clearActivities() {
app.debug.Println("Housekeeping: Cleaning up Activity log...") app.debug.Println("Housekeeping: Cleaning up Activity log...")
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000) keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
@ -147,7 +136,6 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false) clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false) clearTelegram := app.config.Section("telegram").Key("require_unique").MustBool(false)
clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false) clearMatrix := app.config.Section("matrix").Key("require_unique").MustBool(false)
clearPWR := app.config.Section("captcha").Key("enabled").MustBool(false) && !app.config.Section("captcha").Key("recaptcha").MustBool(false)
if clearEmail || clearDiscord || clearTelegram || clearMatrix { if clearEmail || clearDiscord || clearTelegram || clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() }) daemon.jobs = append(daemon.jobs, func(app *appContext) { app.jf.CacheExpiry = time.Now() })
@ -165,9 +153,6 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
if clearMatrix { if clearMatrix {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() }) daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
} }
if clearPWR {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() })
}
return &daemon return &daemon
} }

View File

@ -26,7 +26,7 @@
<body class="max-w-full overflow-x-hidden section"> <body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }} {{ template "login-modal.html" . }}
<div id="modal-add-user" class="modal"> <div id="modal-add-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-user" href="">
<span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.newUser }} <span class="modal-close">&times;</span></span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="add-user-user">
<input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}"> <input type="email" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.emailAddress }}">
@ -43,31 +43,31 @@
</form> </form>
</div> </div>
<div id="modal-about" class="modal"> <div id="modal-about" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-1/3 content card">
<img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner"> <img src="{{ .urlBase }}/banner.svg" class="banner header" alt="jfa-go banner">
<span class="heading"><span class="modal-close">&times;</span></span> <span class="heading"><span class="modal-close">&times;</span></span>
<p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p> <p>{{ .strings.version }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .version }}</span></p>
<p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p> <p>{{ .strings.commitNoun }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .commit }}</span></p>
<p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p> <p>{{ .strings.buildTime }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .buildTime }}</span></p>
<p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p> <p>{{ .strings.builtBy }} <span class="text-black dark:text-white font-mono bg-inherit">{{ .builtBy }}</span></p>
<div class="flex flex-row flex-wrap gap-2 my-2"> <div class="row col flex">
<a class="button ~neutral lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a> <a class="button ~neutral mr-2 mt-4 mb-4 lang-link" href="https://github.com/hrfee/jfa-go"><i class="ri-github-line mr-2"></i>github</a>
<a class="button ~urge lang-link" href="https://wiki.jfa-go.com">wiki/docs</a> <a class="button ~urge mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive lang-link" href="https://weblate.jfa-go.com">translation</a> <a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown" tabindex="0"> <div class="dropdown mr-2" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link"> <a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info mt-4 mb-4 dropdown-button lang-link">
<i class="ri-hand-heart-line mr-2"></i> <i class="ri-hand-heart-line mr-2"></i>
donate donate
<span class="ml-2 chev"></span> <span class="ml-2 chev"></span>
</a> </a>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">GitHub</a> <a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">GitHub</a>
<a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</a> <a href="https://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-100 lang-link">Ko-fi</a>
</div> </div>
</div> </div>
</div> </div>
<a class="button ~urge @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a> <a class="button ~urge mt-4 mb-4 @low discord lang-link" href="https://discord.com/invite/MrtvuQmyhP" target="_blank"><i class="ri-discord-line mr-2"></i>discord</a>
</div> </div>
<p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p> <p><a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE">Available under the MIT License. Font "Hanken Grotesk" available under SIL OFL 1.1 License.</a></p>
<pre class="font-mono bg-inherit">{{ .license }}</pre> <pre class="font-mono bg-inherit">{{ .license }}</pre>
@ -80,15 +80,15 @@
</div> </div>
</div> </div>
<div id="modal-modify-user" class="modal"> <div id="modal-modify-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-modify-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-modify-user" href="">
<span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-modify-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p> <p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<div class="flex flex-row mb-4"> <div class="flex flex-row mb-4">
<label class="grow mr-2"> <label class="flex-row-group mr-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked> <input type="radio" name="modify-user-source" class="unfocused" id="radio-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label> </label>
<label class="grow ml-2"> <label class="flex-row-group ml-2">
<input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user"> <input type="radio" name="modify-user-source" class="unfocused" id="radio-use-user">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label> </label>
@ -111,15 +111,15 @@
</div> </div>
{{ if .referralsEnabled }} {{ if .referralsEnabled }}
<div id="modal-enable-referrals-user" class="modal"> <div id="modal-enable-referrals-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-user" href="">
<span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-enable-referrals-user"></span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p> <p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<div class="flex flex-row mb-4"> <div class="flex flex-row mb-4">
<label class="grow mr-2"> <label class="flex-row-group mr-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked> <input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-profile" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</label> </label>
<label class="grow ml-2"> <label class="flex-row-group ml-2">
<input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite"> <input type="radio" name="enable-referrals-user-source" class="unfocused" id="radio-referrals-use-invite">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label> </label>
@ -142,7 +142,7 @@
</form> </form>
</div> </div>
<div id="modal-enable-referrals-profile" class="modal"> <div id="modal-enable-referrals-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p> <p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label> <label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
@ -162,7 +162,7 @@
</div> </div>
{{ end }} {{ end }}
<div id="modal-delete-user" class="modal"> <div id="modal-delete-user" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-delete-user" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
<span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-delete-user"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="content mt-8">
<label class="switch mb-4"> <label class="switch mb-4">
@ -178,7 +178,7 @@
</form> </form>
</div> </div>
<div id="modal-extend-expiry" class="modal"> <div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8"> <div class="content mt-8">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside> <aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
@ -242,7 +242,7 @@
<div id="modal-announce" class="modal"> <div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href=""> <form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span> <span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="flex flex-col md:flex-row"> <div class="row">
<div class="col card ~neutral @low"> <div class="col card ~neutral @low">
<div id="announce-details"> <div id="announce-details">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span> <span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
@ -259,7 +259,7 @@
<input type="text" class="input ~neutral @low mb-2 mt-4"> <input type="text" class="input ~neutral @low mb-2 mt-4">
<p class="support">{{ .strings.templateEnterName }}</p> <p class="support">{{ .strings.templateEnterName }}</p>
</label> </label>
<div class="flex flex-row justify-between"> <div class="row flex-expand">
<label> <label>
<input type="submit" class="unfocused"> <input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span> <span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@ -275,10 +275,10 @@
</form> </form>
</div> </div>
<div id="modal-customize" class="modal"> <div id="modal-customize" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.customizeMessages }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p> <p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class=""> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -319,7 +319,7 @@
</form> </form>
</div> </div>
<div id="modal-restart" class="modal"> <div id="modal-restart" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~critical @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~critical @low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p> <p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right"> <div class="float-right">
@ -329,7 +329,7 @@
</div> </div>
</div> </div>
<div id="modal-backups" class="modal"> <div id="modal-backups" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.backups }} <span class="modal-close">&times;</span></span>
<div class="content my-4"> <div class="content my-4">
{{ .strings.backupsDescription }} {{ .strings.backupsDescription }}
@ -345,7 +345,7 @@
<input id="backups-file" name="backups-file" type="file" hidden> <input id="backups-file" name="backups-file" type="file" hidden>
<button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button> <button class="button ~neutral @low mr-2 mb-2" id="settings-backups-sort-direction">{{ .strings.sortDirection }}</button>
</div> </div>
<div class="overflow-x-auto text-xs md:text-sm"> <div class="overflow-x-auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -360,30 +360,30 @@
</div> </div>
</div> </div>
<div id="modal-backed-up" class="modal"> <div id="modal-backed-up" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.backupCreated }} <span class="modal-close">&times;</span></span>
<p class="content my-4" id="settings-backed-up-location"></p> <p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p> <p class="content my-4">{{ .strings.backupCanDownload }}</p>
<div> <div>
<button class="button flex w-full ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button> <button class="button flex w-100 ~info @low mb-2"><span class="flex items-center" id="settings-backed-up-download">{{ .strings.download }}</span></button>
</div> </div>
</div> </div>
</div> </div>
<div id="modal-refresh" class="modal"> <div id="modal-refresh" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.settingsApplied }}</span> <span class="heading">{{ .strings.settingsApplied }}</span>
<p class="content">{{ .strings.settingsRefreshPage }}</p> <p class="content">{{ .strings.settingsRefreshPage }}</p>
</div> </div>
</div> </div>
<div id="modal-send-pwr" class="modal"> <div id="modal-send-pwr" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 ~neutral @low"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3 ~neutral @low">
<span class="heading">{{ .strings.sendPWR }}</span> <span class="heading">{{ .strings.sendPWR }}</span>
<p class="content my-2" id="send-pwr-note"></p> <p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span> <span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div> </div>
</div> </div>
<div id="modal-ombi-profile" class="modal"> <div id="modal-ombi-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-ombi-defaults" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-ombi-defaults" href="">
<span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.ombiProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p> <p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4"> <div class="select ~neutral @low mb-4">
@ -396,7 +396,7 @@
</form> </form>
</div> </div>
<div id="modal-user-profiles" class="modal"> <div id="modal-user-profiles" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-2/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.userProfiles }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p> <p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive"> <div class="table-responsive">
@ -422,7 +422,7 @@
</div> </div>
</div> </div>
<div id="modal-add-profile" class="modal"> <div id="modal-add-profile" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-add-profile" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-add-profile" href="">
<span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.addProfile }} <span class="modal-close">&times;</span></span>
<p class="content my-4">{{ .strings.addProfileDescription }}</p> <p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label> <label>
@ -445,7 +445,7 @@
</form> </form>
</div> </div>
<div id="modal-update" class="modal"> <div id="modal-update" class="modal">
<div class="relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span> <span class="heading">{{ .strings.updates }} <span class="modal-close">&times;</span></span>
<p class="content"> <p class="content">
<h2 class="mt-2"> <h2 class="mt-2">
@ -461,7 +461,7 @@
</div> </div>
{{ if .telegramEnabled }} {{ if .telegramEnabled }}
<div id="modal-telegram" class="modal"> <div id="modal-telegram" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4">{{ .strings.linkTelegram }}</span> <span class="heading mb-4">{{ .strings.linkTelegram }}</span>
<p class="content mb-4">{{ .strings.sendPIN }}</p> <p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1> <h1 class="ac" id="telegram-pin"></h1>
@ -479,7 +479,7 @@
{{ end }} {{ end }}
{{ if .discordEnabled }} {{ if .discordEnabled }}
<div id="modal-discord" class="modal"> <div id="modal-discord" class="modal">
<div class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3"> <div class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3">
<span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span> <span class="heading mb-4"><span id="discord-header"></span><span class="modal-close">&times;</span></span>
<p class="content mb-4" id="discord-description"></p> <p class="content mb-4" id="discord-description"></p>
<div class="row"> <div class="row">
@ -490,7 +490,7 @@
</div> </div>
{{ end }} {{ end }}
<div id="modal-matrix" class="modal"> <div id="modal-matrix" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-matrix" href=""> <form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-matrix" href="">
<span class="heading">{{ .strings.linkMatrix }}</span> <span class="heading">{{ .strings.linkMatrix }}</span>
<p class="content my-4">{{ .strings.linkMatrixDescription }}</p> <p class="content my-4">{{ .strings.linkMatrixDescription }}</p>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.matrixHomeServer }}" id="matrix-homeserver">
@ -503,7 +503,7 @@
</form> </form>
</div> </div>
<div id="notification-box"></div> <div id="notification-box"></div>
<div class="top-4 left-4 absolute flex flex-row gap-2"> <div class="top-4 left-4 absolute">
<span class="dropdown z-[11]" tabindex="0" id="lang-dropdown"> <span class="dropdown z-[11]" tabindex="0" id="lang-dropdown">
<span class="button ~urge dropdown-button"> <span class="button ~urge dropdown-button">
<i class="ri-global-line"></i> <i class="ri-global-line"></i>
@ -532,8 +532,8 @@
{{ end }} {{ end }}
<div class="page-container"> <div class="page-container">
<div class="mb-4"> <div class="mb-4">
<header> <header class="flex flex-wrap items-center justify-between">
<div class="flex flex-row overflow-x-scroll items-center"> <div>
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span> <span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span> <span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span> <span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
@ -547,55 +547,55 @@
</div> </div>
</div> </div>
<div id="tab-invites"> <div id="tab-invites">
<div class="card @low invites dark:~d_neutral mb-4 overflow-visible"> <div class="card @low invites dark:~d_neutral mb-4">
<span class="heading">{{ .strings.invites }}</span> <span class="heading">{{ .strings.invites }}</span>
<div id="invites" class="mt-2"></div> <div id="invites"></div>
</div> </div>
<div class="card @low dark:~d_neutral"> <div class="card @low dark:~d_neutral">
<span class="heading">{{ .strings.create }}</span> <span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv"> <div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 w-1/2"> <div class="card ~neutral @low col">
<div class="flex flex-row gap-2"> <div class="row mb-2">
<label class="w-1/2"> <label class="col mr-2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked> <input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span> <span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label> </label>
<label class="w-1/2"> <label class="col ml-2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry"> <input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span> <span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label> </label>
</div> </div>
<div id="inv-duration" class="flex flex-col gap-2"> <div id="inv-duration">
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="create-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-months"> <select id="create-months">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="create-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-days"> <select id="create-days">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label> <label class="label supra" for="create-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-hours"> <select id="create-hours">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label> <label class="label supra" for="create-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="create-minutes"> <select id="create-minutes">
<option>0</option> <option>0</option>
</select> </select>
@ -603,46 +603,44 @@
</div> </div>
</div> </div>
</div> </div>
<div id="user-expiry" class="unfocused flex flex-col gap-2"> <div id="user-expiry" class="unfocused">
<div class="flex flex-row gap-2"> <p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<p class="support">{{ .strings.userExpiryDescription }}</p> <div class="mb-2">
<div> <label for="create-user-expiry-enabled" class="button ~neutral @low">
<label for="create-user-expiry-enabled" class="button ~neutral @low"> <input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled"> <span class="ml-2">{{ .strings.enabled }} </span>
<span class="ml-2">{{ .strings.enabled }} </span> </label>
</label>
</div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label> <label class="label supra" for="user-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-months"> <select id="user-months">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-days">{{ .strings.inviteDays }}</label> <label class="label supra" for="user-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-days"> <select id="user-days">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-row gap-2"> <div class="row">
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label> <label class="label supra" for="user-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-hours"> <select id="user-hours">
<option>0</option> <option>0</option>
</select> </select>
</div> </div>
</div> </div>
<div class="grow flex flex-col gap-4"> <div class="col">
<label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label> <label class="label supra" for="user-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low"> <div class="select ~neutral @low mb-2 mt-4">
<select id="user-minutes"> <select id="user-minutes">
<option>0</option> <option>0</option>
</select> </select>
@ -650,91 +648,77 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="col">
<label class="label supra" for="create-label"> {{ .strings.label }}</label> <label class="label supra" for="create-label"> {{ .strings.label }}</label>
<input type="text" id="create-label" class="input ~neutral @low"> <input type="text" id="create-label" class="input ~neutral @low mb-2 mt-4">
</div> </div>
<div class="flex flex-col gap-4"> <div class="col">
<div> <label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label> <p class="support">{{ .strings.userLabelDescription }}</p>
<p class="support">{{ .strings.userLabelDescription }}</p> <input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
</div>
<input type="text" id="create-user-label" class="input ~neutral @low">
</div> </div>
</div> </div>
<div class="card ~neutral @low flex flex-col justify-between gap-2 w-1/2"> <div class="card ~neutral @low col">
<div class="flex flex-col gap-2"> <label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex flex-col gap-4"> <div class="flex-expand mb-2 mt-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label> <input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<div class="flex flex-row gap-2"> <label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1> <span>&infin;</span>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite"> <input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
<span>&infin;</span> </label>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite"> </div>
</label> <p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
</div> <label class="label supra">{{ .strings.profile }}</label>
</div> <div class="select ~neutral @low mb-2 mt-4">
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p> <select id="create-profile">
<div class="flex flex-col gap-4"> </select>
<label class="label supra">{{ .strings.profile }}</label> </div>
<div class="select ~neutral @low"> <div id="create-send-to-container">
<select id="create-profile"> <label class="label supra">{{ .strings.inviteSendToEmail }}</label>
</select> <div class="flex-expand mb-2 mt-4">
</div> {{ if .discordEnabled }}
</div> <input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<div id="create-send-to-container" class="flex flex-col gap-4"> <span id="create-send-to-search" class="button ~neutral @low mr-2">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label> <i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
<div class="flex flex-row gap-2"> </span>
{{ if .discordEnabled }} {{ else }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234"> <input type="email" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com">
<span id="create-send-to-search" class="button ~neutral @low"> {{ end }}
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i> <label for="create-send-to-enabled" class="button ~neutral @low">
</span> <input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
{{ else }} </label>
<input type="email" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com">
{{ end }}
<label for="create-send-to-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-send-to-enabled" aria-label="Send to address enabled">
</label>
</div>
</div> </div>
</div> </div>
<div> <span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="tab-accounts" class="unfocused"> <div id="tab-accounts" class="unfocused">
<div class="card @low dark:~d_neutral accounts mb-4 overflow-visible"> <div class="card @low dark:~d_neutral accounts mb-4 overflow-visible">
<div id="accounts-filter-dropdown" class="dropdown manual z-10 w-full"> <div class="flex-expand align-middle">
<div class="flex flex-col md:flex-row align-middle gap-2"> <span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div class="flex flex-row align-middle justify-between md:justify-normal"> <div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span> <span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="accounts-filter-button" tabindex="0">{{ .strings.filters }}</button></span> <div class="dropdown-display">
</div> <div class="card ~neutral @low mt-2" id="accounts-filter-list">
<div class="flex flex-row align-middle w-full"> <p class="supra pb-2">{{ .strings.filters }}</p>
<input type="search" class="field ~neutral @low input search mr-2" id="accounts-search" placeholder="{{ .strings.search }}"> </div>
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div> </div>
</div> </div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div> </div>
<div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div> <div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2"> <div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button> <button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="accounts-filter-area"></span> <span id="accounts-filter-area"></span>
</div> </div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div> <div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4"> <div class="row -mx-2">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span> <span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0"> <div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span> <span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span> <span class="supra sm">{{ .strings.templates }}</span>
@ -742,12 +726,12 @@
</div> </div>
</div> </div>
</div> </div>
<span class="button ~urge @low center " id="accounts-modify-user">{{ .strings.modifySettings }}</span> <span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }} {{ if .referralsEnabled }}
<span class="button ~urge @low center " id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span> <span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }} {{ end }}
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " tabindex="0"> <div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-full button ~positive @low center items-baseline" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span> <span class="w-100 button ~positive @low center" id="accounts-expiry-dropdown-button">{{ .strings.expiry }} <i class="ri-arrow-down-s-line ml-2"></i></span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span> <span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
@ -755,16 +739,16 @@
</div> </div>
</div> </div>
</div> </div>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0"> <div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span> <span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span> <span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div> </div>
</div> </div>
</div> </div>
<span class="button ~info @low center unfocused " id="accounts-send-pwr">{{ .strings.sendPWR }}</span> <span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="button ~critical @low center " id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span> <span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div> </div>
<div class="card @low accounts-header table-responsive mt-2"> <div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4"> <table class="table text-base leading-4">
@ -807,33 +791,27 @@
</div> </div>
<div id="tab-activity" class="unfocused"> <div id="tab-activity" class="unfocused">
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible"> <div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
<div id="activity-filter-dropdown" class="dropdown manual z-10 w-full" tabindex="0"> <div class="flex-expand align-middle">
<div class="flex flex-col md:flex-row align-middle gap-2"> <span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle justify-between md:justify-normal"> <div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span> <span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="flex flex-row align-middle"> <div class="dropdown-display">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span> <div class="card ~neutral @low mt-2" id="activity-filter-list">
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button> <p class="supra pb-2">{{ .strings.filters }}</p>
</div> </div>
</div> </div>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div>
</div>
<div class="dropdown-display max-w-full">
<div class="card ~neutral @low mt-2 overflow-x-scroll" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div> </div>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
</div> </div>
<div class="flex flex-row justify-between pt-3 pb-2"> <div class="flex flex-row justify-between py-2">
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div> <div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
<div class="supra sm flex flex-row gap-2"> <div class="supra sm">
<span id="activity-total-records"></span> <span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records"></span> <span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records"></span> <span id="activity-shown-records" class="mx-2"></span>
</div> </div>
</div> </div>
<div class="row -mx-2 mb-2"> <div class="row -mx-2 mb-2">
@ -864,24 +842,24 @@
</div> </div>
<div id="tab-settings" class="unfocused"> <div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow"> <div class="card @low dark:~d_neutral settings overflow">
<div class="flex flex-col md:flex-row align-middle gap-2"> <div class="flex-expand">
<div class="flex flex-row align-middle justify-between md:justify-normal"> <div class="flex-row">
<span class="heading">{{ .strings.settings }}</span> <span class="heading">{{ .strings.settings }}</span>
<label for="settings-advanced-enabled" class="button ~neutral @low ml-2"> <label for="settings-advanced-enabled" class="button ~neutral @low ml-2 my-2">
<input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled"> <input type="checkbox" id="settings-advanced-enabled" aria-label="Advanced settings enabled">
<span class="ml-2">{{ .strings.advancedSettings }} </span> <span class="ml-2">{{ .strings.advancedSettings }} </span>
</label> </label>
</div> </div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full"> <div>
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span> <span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span> <span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span> <span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span> <span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div> </div>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col" id="settings-sidebar"> <div class="card @low dark:~d_neutral col" id="settings-sidebar">
<div class="flex flex-row justify-between"> <div class="flex-expand">
<input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input settings-section-button justify-between mb-2" id="settings-search" placeholder="{{ .strings.search }}">
<button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button> <button class="button ~neutral @low center -ml-10 rounded-s-none mb-2 settings-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></button>
</div> </div>

View File

@ -18,7 +18,7 @@
<a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a> <a class="button ~critical mb-4" target="_blank" href="https://github.com/hrfee/jfa-go/issues/new/choose">Create an Issue</a>
</section> </section>
<section class="section ~neutral @low"> <section class="section ~neutral @low">
<div class="flex flex-row justify-between"> <div class="flex-expand">
<span class="subheading">Full Log</span> <span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span> <span class="button ~urge ml-4" id="copy-log">Copy</span>
</div> </div>

View File

@ -34,12 +34,8 @@
</script> </script>
{{ if .passwordReset }} {{ if .passwordReset }}
<script src="js/pwr.js" type="module"></script> <script src="js/pwr.js" type="module"></script>
<script>
window.pwrPIN = "{{ .pwrPIN }}";
</script>
{{ else }} {{ else }}
<script src="js/form.js" type="module"></script> <script src="js/form.js" type="module"></script>
{{ end }}
{{ if .reCAPTCHA }} {{ if .reCAPTCHA }}
<script> <script>
var reCAPTCHACallback = () => { var reCAPTCHACallback = () => {
@ -53,3 +49,4 @@
<script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script> <script src="https://www.google.com/recaptcha/api.js?onload=reCAPTCHACallback&render=explicit" async defer></script>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ end }}

View File

@ -1,21 +1,21 @@
<div id="modal-login" class="modal"> <div id="modal-login" class="modal">
<div class="my-[10%] row items-stretch relative mx-auto w-11/12 sm:w-4/5 lg:w-1/2"> <div class="my-[10%] row items-stretch relative mx-auto w-[40%] lg:w-[60%]">
{{ if index . "LoginMessageEnabled" }} {{ if index . "LoginMessageEnabled" }}
{{ if .LoginMessageEnabled }} {{ if .LoginMessageEnabled }}
<div class="card mx-2 flex-initial w-[100%] lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content"> <div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
{{ .LoginMessageContent }} {{ .LoginMessageContent }}
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ if index . "userPageEnabled" }} {{ if index . "userPageEnabled" }}
{{ if and .userPageEnabled .showUserPageLink }} {{ if and .userPageEnabled .showUserPageLink }}
<div class="card mx-2 flex-initial w-[100%] lg:w-[35%] mb-4 lg:mb-0 dark:~d_neutral @low content"> <div class="card mx-2 flex-initial w-[100%] xl:w-[35%] mb-4 xl:mb-0 dark:~d_neutral @low content">
<span class="heading row">{{ .strings.loginNotAdmin }}</span> <span class="heading row">{{ .strings.loginNotAdmin }}</span>
<a class="button ~info h-12 w-full" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a> <a class="button ~info h-12 w-100" href="{{ .urlBase }}/my/account"><i class="ri-account-circle-fill mr-2"></i>{{ .strings.myAccount }}</a>
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
<form class="card mx-2 form-login w-[100%] lg:w-[55%] mb-0" href=""> <form class="card mx-2 flex-auto form-login w-[100%] xl:w-[55%] mb-0" href="">
<span class="heading">{{ .strings.login }}</span> <span class="heading">{{ .strings.login }}</span>
<input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user"> <input type="text" class="field input ~neutral @high mt-4 mb-2" placeholder="{{ .strings.username }}" id="login-user">
<input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password"> <input type="password" class="field input ~neutral @high mb-4" placeholder="{{ .strings.password }}" id="login-password">

View File

@ -35,7 +35,7 @@
<aside class="aside ~warning"> <aside class="aside ~warning">
{{ .strings.changeYourPassword }} {{ .strings.changeYourPassword }}
</aside> </aside>
<span class="button ~urge @low w-full text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span> <span class="button ~urge @low w-100 text-center text-xl p-1 mt-4" id="pin" title="{{ .strings.copy }}">{{ .pin }}</span>
{{ end }} {{ end }}
</div> </div>
<i class="content">{{ .contactMessage }}</i> <i class="content">{{ .contactMessage }}</i>

View File

@ -30,7 +30,7 @@
<div class="row col flex center"> <div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p> <p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="support">{{ .lang.StartPage.httpsNotice }}</span> <span class="support">{{ .lang.StartPage.httpsNotice }}</span>
<span class="button ~urge @low next">{{ .lang.StartPage.start }}</span> <span class="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section> </section>
@ -59,7 +59,7 @@
</select> </select>
</div> </div>
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
@ -150,7 +150,7 @@
</label> </label>
</div> </div>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
@ -189,7 +189,7 @@
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span> <span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label> </label>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
</section> </section>
@ -235,7 +235,7 @@
</label> </label>
</div> </div>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span> <span class="button ~urge @low" id="jellyfin-test-connection">{{ .lang.JellyfinEmby.testConnection }}</span>
@ -258,7 +258,7 @@
<input type="text" class="input ~neutral @low mt-4" id="ombi-api_key"> <input type="text" class="input ~neutral @low mt-4" id="ombi-api_key">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -273,7 +273,7 @@
<input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span> <input type="checkbox" class="mr-2" id="userpage-enabled"><span>{{ .lang.Strings.enabled }}</span>
</label> </label>
<p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p> <p class="support mb-1 mt-1">{{ .lang.UserPage.requiredSettings }}</p>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -370,7 +370,7 @@
</div> </div>
</div> </div>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -392,7 +392,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="welcome_email-subject" placeholder="{{ .emailLang.WelcomeEmail.title }}">
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -413,7 +413,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="invite_emails-subject" placeholder="{{ .emailLang.InviteEmail.title }}">
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -450,7 +450,7 @@
<span class="mt-4">{{ .lang.Strings.emailSubject }}</span> <span class="mt-4">{{ .lang.Strings.emailSubject }}</span>
<input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}"> <input type="text" class="input ~neutral @low mt-4 mb-2" id="password_resets-subject" placeholder="{{ .emailLang.PasswordReset.title }}">
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -483,7 +483,7 @@
<span class="mt-4">{{ .lang.PasswordValidation.special }}</span> <span class="mt-4">{{ .lang.PasswordValidation.special }}</span>
<input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0"> <input type="number" class="input ~neutral @low mt-4 mb-2" id="password_validation-special" value="0">
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -513,7 +513,7 @@
<input type="text" class="input ~neutral @low mt-4" id="email-message"> <input type="text" class="input ~neutral @low mt-4" id="email-message">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p> <p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</label> </label>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<span class="button ~neutral @low back">{{ .lang.Strings.back }}</span> <span class="button ~neutral @low back">{{ .lang.Strings.back }}</span>
<div> <div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span> <span class="button ~urge @low next">{{ .lang.Strings.next }}</span>

View File

@ -109,10 +109,10 @@
<div class="card @low dark:~d_neutral mb-4" id="card-user"> <div class="card @low dark:~d_neutral mb-4" id="card-user">
<span class="heading mb-2"></span> <span class="heading mb-2"></span>
</div> </div>
<div class="columns-1 sm:columns-2 gap-4" id="user-cardlist"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{{ if index . "PageMessageEnabled" }} {{ if index . "PageMessageEnabled" }}
{{ if .PageMessageEnabled }} {{ if .PageMessageEnabled }}
<div class="card @low dark:~d_neutral content break-words" id="card-message"> <div class="card @low dark:~d_neutral content" id="card-message">
{{ .PageMessageContent }} {{ .PageMessageContent }}
</div> </div>
{{ end }} {{ end }}
@ -162,7 +162,7 @@
<div class="card @low dark:~d_neutral unfocused" id="card-referrals"> <div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span> <span class="heading mb-2">{{ .strings.referrals }}</span>
<aside class="aside ~neutral my-4 col user-referrals-description"></aside> <aside class="aside ~neutral my-4 col user-referrals-description"></aside>
<div class="flex flex-row justify-between gap-2"> <div class="row flex-expand">
<div class="user-referrals-info"></div> <div class="user-referrals-info"></div>
<div class="grid my-2"> <div class="grid my-2">
<button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button> <button type="button" class="user-referrals-button button ~info dark:~d_info @low" title="Copy">{{ .strings.copyReferral }}<i class="ri-file-copy-line ml-2"></i></button>

View File

@ -32,12 +32,12 @@
"before": "قبل", "before": "قبل",
"user": "مستخدم", "user": "مستخدم",
"userExpiry": "انتهاء صلاحية المستخدم", "userExpiry": "انتهاء صلاحية المستخدم",
"userExpiryDescription": "بعد وقت محدد من تسجيل مستخدم جديد, jfa-go سوف يمسح\\يلغي تفعيل الحساب. بامكانك تغيير هذا السلوك في الاعدادات.", "userExpiryDescription": "",
"aboutProgram": "حول", "aboutProgram": "حول",
"version": "إصدار", "version": "إصدار",
"commitNoun": "فرض", "commitNoun": "تعديل",
"newUser": "مستخدم جديد", "newUser": "مستخدم جديد",
"profile": "حساب تعريفي", "profile": "ملف",
"unknown": "غير معروف", "unknown": "غير معروف",
"label": "وسم", "label": "وسم",
"logs": "السجلات", "logs": "السجلات",
@ -63,19 +63,19 @@
"markdownSupported": "", "markdownSupported": "",
"modifySettings": "", "modifySettings": "",
"modifySettingsDescription": "", "modifySettingsDescription": "",
"applyHomescreenLayout": "تطبيق ترتيب الصفحه الرئيسيه", "applyHomescreenLayout": "",
"sendDeleteNotificationEmail": "ارسال رساله اشعار", "sendDeleteNotificationEmail": "",
"sendDeleteNotifiationExample": "تم حذف حسابك.", "sendDeleteNotifiationExample": "",
"settingsRestart": "اعاده تشغيل", "settingsRestart": "",
"settingsRestarting": "اعاده التشغيل…", "settingsRestarting": "",
"settingsRestartRequired": "يجب اعاده التشغيل", "settingsRestartRequired": "",
"settingsRestartRequiredDescription": "يجب اعاده التشغيل لتطبيق بعض الاعدادات التي تم تغييرها. اعاده التشغيل الان ام لاحقا؟", "settingsRestartRequiredDescription": "",
"settingsApplyRestartLater": "تطبيق الاعدادات, اعاده التشغيل لاحقا", "settingsApplyRestartLater": "",
"settingsApplyRestartNow": "تطبيق الاعدادات و اعاده التشغيل", "settingsApplyRestartNow": "",
"settingsApplied": "تم تطبيق الاعدادات.", "settingsApplied": "",
"settingsRefreshPage": "اعد انعاش الصفحه بعد بضع ثوان.", "settingsRefreshPage": "",
"settingsRequiredOrRestartMessage": "ملاحظه: {n} تشير الى حقل اجباري, {n} تشير ان التغييرات تحتاج لاعاده التشغيل.", "settingsRequiredOrRestartMessage": "",
"settingsSave": "حفظ", "settingsSave": "",
"ombiProfile": "", "ombiProfile": "",
"ombiUserDefaultsDescription": "", "ombiUserDefaultsDescription": "",
"userProfiles": "", "userProfiles": "",
@ -117,15 +117,7 @@
"userPageLogin": "", "userPageLogin": "",
"userPagePage": "", "userPagePage": "",
"buildTime": "", "buildTime": "",
"builtBy": "", "builtBy": ""
"activity": "الانشطه",
"userLabel": "وسم المستخدم",
"userLabelDescription": "الوسام للمستخدمين المفعلين من هذه الدعوه.",
"enableReferrals": "تفعيل الاحالات",
"disableReferrals": "ابطال الاحالات",
"invite": "دعوه",
"enableReferralsProfileDescription": "تمكين المستخدمين من هذا الحساب التعريفي للاحالات الخاصه, لارسالها للعائله\\الاصدقاء. انشاء دعوه بالاعدادات المطلوبه, ثم اختارها هنا. كل احاله سوف تكون مبنيه على اعدادات هذه الدعوه. بامكانك مسح الدعوه عند لانتهاء.",
"enableReferralsDescription": "تمكين المستخدمين لاستعمال احالات خاصه مثل الدعوه, لارسالها للعائله\\للاصدقاء. ممكن اصدارها من قوالب الاحالات في الحساب التعريفي, او من دعوه مفعله."
}, },
"notifications": { "notifications": {
"changedEmailAddress": "", "changedEmailAddress": "",

View File

@ -115,8 +115,7 @@
"after": "nach", "after": "nach",
"before": "vor", "before": "vor",
"unlink": "Account trennen", "unlink": "Account trennen",
"sortingBy": "Sortieren nach", "sortingBy": "Sortieren nach"
"activity": "Aktivität"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "E-Mail-Adresse von {n} geändert.", "changedEmailAddress": "E-Mail-Adresse von {n} geändert.",

View File

@ -150,8 +150,7 @@
"accountDisabled": "Account disabled: {user}", "accountDisabled": "Account disabled: {user}",
"accountReEnabled": "Account re-enabled: {user}", "accountReEnabled": "Account re-enabled: {user}",
"accountExpired": "Account expired: {user}", "accountExpired": "Account expired: {user}",
"accountWillExpire": "Account will expire on {date}.", "accountWillExpire": "Account will expire on {date}",
"expirationBasedOn": "Given date based on 1st user.",
"userDeleted": "User was deleted.", "userDeleted": "User was deleted.",
"userDisabled": "User was disabled", "userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}", "inviteCreated": "Invite created: {invite}",

View File

@ -130,53 +130,7 @@
"searchOptions": "Zoekopties", "searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst", "matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID", "jellyfinID": "Jellyfin ID",
"userPagePage": "Gebruikerspagina: Pagina", "userPagePage": "Gebruikerspagina: Pagina"
"activity": "Activiteit",
"deleted": "Verwijderd",
"disabled": "Uitgeschakeld",
"keepSearching": "Blijf zoeken",
"keepSearchingDescription": "Alleen momenteel ingeladen activiteiten zijn doorzocht. Klik hieronder om alle activiteiten te doorzoeken.",
"sortDirection": "Sorteerrichting",
"referrer": "Verwijzer",
"accountLinked": "{contactMethod} gekoppeld: {user}",
"accountUnlinked": "{contactMethod} verwijderd: {user}",
"accountResetPassword": "{user} heeft hun wachtwoord gereset",
"accountChangedPassword": "{user} heeft hun wachtwoord gewijzigd",
"accountDisabled": "Account uitgeschakeld: {user}",
"accountDeleted": "Account verwijderd: {user}",
"accountCreated": "Account aangemaakt: {user}",
"accountReEnabled": "Account opnieuw ingeschakeld: {user}",
"accountExpired": "Account verlopen: {user}",
"userDeleted": "Gebruiker is verwijderd.",
"userDisabled": "Gebruiker is uitgeschakeld",
"inviteCreated": "Uitnodiging aangemaakt: {invite}",
"inviteDeleted": "Uitnodiging verwijderd: {invite}",
"inviteExpired": "Uitnodiging verlopen: {invite}",
"fromInvite": "Via uitnodiging",
"byAdmin": "Door beheerder",
"byUser": "Door gebruiker",
"byJfaGo": "Door jfa-go",
"activityID": "Activiteit ID",
"title": "Titel",
"usersMentioned": "Genoemde gebruiker",
"actor": "Uitvoerder",
"actorDescription": "Wat deze actie veroorzaakt heeft. \"gebruiker\"/\"beheerder\"/\"daemon\" of een gebruikersnaam.",
"accountCreationFilter": "Aanmaken van account",
"accountDeletionFilter": "Verwijderen van account",
"accountDisabledFilter": "Account uitgeschakeld",
"accountEnabledFilter": "Account ingeschakeld",
"contactLinkedFilter": "Contact gekoppeld",
"contactUnlinkedFilter": "Contact ontkoppeld",
"passwordChangeFilter": "Wachtwoord gewijzigd",
"passwordResetFilter": "Wachtwoord gereset",
"inviteCreatedFilter": "Uitnodiging aangemaakt",
"inviteDeletedFilter": "Uitnodiging verwijderd/verlopen",
"loadMore": "Laad meer",
"loadAll": "Laad alles",
"noMoreResults": "Niet meer resultaten.",
"totalRecords": "{n} documenten totaal",
"loadedRecords": "{n} geladen",
"shownRecords": "{n} getoond"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.", "changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@ -216,11 +170,7 @@
"setOmbiProfile": "Opgeslagen ombi-profiel.", "setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.", "errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.", "errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief.", "referralsEnabled": "Verwijzingen actief."
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt."
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {

View File

@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值", "ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。", "ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案", "userProfiles": "用户档案",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。", "userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认", "userProfilesIsDefault": "默认",
"userProfilesLibraries": "库", "userProfilesLibraries": "库",
"addProfile": "添加档案", "addProfile": "添加档案",
@ -117,66 +117,7 @@
"before": "之前", "before": "之前",
"unlink": "取消关联帐户", "unlink": "取消关联帐户",
"sortingBy": "排序方式", "sortingBy": "排序方式",
"userPageLogin": "用户页面:登录", "userPageLogin": "用户页面:登录"
"activity": "活动",
"userLabelDescription": "标签应用于使用此邀请创建的用户。",
"disabled": "禁用",
"keepSearchingDescription": "只有当前加载的活动被搜索了。如果您想搜索所有活动,请点击下方。",
"enableReferralsDescription": "为用户提供一个个人的推荐链接,类似于邀请,以便他们发送给朋友和家人。可以从个人资料中的推荐模板获取,或从现有的邀请中获取。",
"userDeleted": "用户已被删除。",
"inviteCreated": "邀请已创建:{invite}",
"usersMentioned": "用户提到的",
"actorDescription": "引起这个操作的事物。可以是“用户”、“管理员”、“守护程序”或用户名。",
"loginNotAdmin": "不是管理员?",
"invite": "邀请",
"noResultsFound": "没有发现任何结果",
"settingsHiddenDependency": "匹配设置被隐藏,因为它们取决于另一个设置的值:",
"settingsDependsOn": "{setting}:依赖于 {dependency}",
"settingsAdvancedMode": "{setting}:必须启用高级设置",
"settingsMaybeUnderAdvanced": "提示:通过启用高级设置,您可能会找到您正在寻找的内容。",
"userLabel": "用户标签",
"deleted": "删除",
"keepSearching": "继续搜索",
"enableReferrals": "启用推荐",
"disableReferrals": "禁用推荐",
"enableReferralsProfileDescription": "为使用该个人资料创建的用户提供一个类似邀请的个人推荐链接,以便他们发送给朋友和家人。创建一个具有所需设置的邀请,然后在此处进行选择。然后,每个推荐都将基于这个邀请。完成后,您可以删除邀请。",
"sortDirection": "排序方向",
"referrer": "推荐人",
"accountLinked": "{contactMethod} 已关联:{user}",
"accountUnlinked": "{contactMethod} 已移除:{user}",
"accountResetPassword": "{user} 重置了他们的密码",
"accountChangedPassword": "{user} 更改了他们的密码",
"accountCreated": "账户已创建:{user}",
"accountDeleted": "账户已删除:{user}",
"accountDisabled": "账户已禁用:{user}",
"accountReEnabled": "账户已重新启用:{user}",
"accountExpired": "账户已过期:{user}",
"userDisabled": "用户已被禁用",
"inviteDeleted": "邀请已删除:{invite}",
"inviteExpired": "邀请已过期:{invite}",
"fromInvite": "来自邀请",
"byAdmin": "由管理员发起的",
"byUser": "由用户发起的",
"byJfaGo": "由jfa-go发起的",
"activityID": "活动ID",
"title": "标题",
"actor": "角色",
"accountCreationFilter": "账户创建",
"accountDeletionFilter": "账户删除",
"accountDisabledFilter": "账户禁用",
"accountEnabledFilter": "账户启用",
"contactLinkedFilter": "联系方式已关联",
"contactUnlinkedFilter": "联系方式未关联",
"passwordChangeFilter": "密码已更改",
"passwordResetFilter": "密码重置",
"inviteCreatedFilter": "邀请已创建",
"inviteDeletedFilter": "邀请已删除/过期",
"loadMore": "加载更多",
"loadAll": "加载全部",
"noMoreResults": "没有更多结果了。",
"totalRecords": "{n} 总记录数",
"loadedRecords": "已加载{n}",
"shownRecords": "已显示{n}"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "更改了 {n} 的电子邮件地址。", "changedEmailAddress": "更改了 {n} 的电子邮件地址。",
@ -214,13 +155,7 @@
"updateAvailable": "有新更新可用,请检查设置。", "updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。", "noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。", "setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。", "errorSetOmbiProfile": "无法保存ombi配置文件。"
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。"
}, },
"quantityStrings": { "quantityStrings": {
"modifySettingsFor": { "modifySettingsFor": {
@ -278,10 +213,6 @@
"setExpiry": { "setExpiry": {
"plural": "为{n}用户设置到期时间", "plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间" "singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
} }
} }
} }

View File

@ -29,7 +29,7 @@
"logout": "登出", "logout": "登出",
"admin": "管理员", "admin": "管理员",
"enabled": "已启用", "enabled": "已启用",
"disabled": "禁用", "disabled": "禁用",
"reEnable": "重新启用", "reEnable": "重新启用",
"disable": "禁用", "disable": "禁用",
"expiry": "到期", "expiry": "到期",
@ -40,8 +40,7 @@
"contactMethods": "联系方式", "contactMethods": "联系方式",
"accountStatus": "帐户状态", "accountStatus": "帐户状态",
"notSet": "未设置", "notSet": "未设置",
"myAccount": "我的帐户", "myAccount": "我的帐户"
"referrals": "推荐"
}, },
"notifications": { "notifications": {
"errorLoginBlank": "用户名/密码留空。", "errorLoginBlank": "用户名/密码留空。",

View File

@ -34,10 +34,7 @@
"welcomeUser": "欢迎,{user}", "welcomeUser": "欢迎,{user}",
"editContactMethod": "修改联系方式", "editContactMethod": "修改联系方式",
"joinTheServer": "加入服务器:", "joinTheServer": "加入服务器:",
"customMessagePlaceholderHeader": "自定义此卡", "customMessagePlaceholderHeader": "自定义此卡"
"referralsDescription": "使用此链接邀请朋友和家人加入Jellyfin。如果链接过期请回到这里获取一个新的。",
"copyReferral": "复制链接",
"invitedBy": "您是由用户{user}邀请的。"
}, },
"notifications": { "notifications": {
"errorUserExists": "用户已经存在。", "errorUserExists": "用户已经存在。",

View File

@ -22,8 +22,7 @@
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.", "errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd.", "errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.", "errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout", "error": "Fout"
"errorProxy": "Proxy-instellingen onjuist."
}, },
"startPage": { "startPage": {
"welcome": "Welkom!", "welcome": "Welkom!",
@ -157,11 +156,5 @@
"title": "Gebruikerspagina", "title": "Gebruikerspagina",
"customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.", "customizeMessages": "Gebruik de bewerken knop naast \"Gebruikerspagina\" in de instellingen om dit later in te stellen.",
"requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets." "requiredSettings": "Inloggen bij jfa-go via Jellyfin moet ingesteld zijn. Controleer dat \"reset wachtwoord via link\" later wordt gekozen voor zelfservice wachtwoord-resets."
},
"proxy": {
"title": "Proxy",
"description": "Laat jfa-go alle verbindingen via een HTTP/SOCKS5 proxy maken. De verbinding met Jellyfin wordt hierdoorheen getest.",
"protocol": "Protocol",
"address": "Adres (inclusief poort)"
} }
} }

View File

@ -8,7 +8,7 @@
"back": "上一步", "back": "上一步",
"optional": "可选的", "optional": "可选的",
"serverType": "服务类型", "serverType": "服务类型",
"disabled": "禁用", "disabled": "禁用",
"enabled": "已启用", "enabled": "已启用",
"port": "端口", "port": "端口",
"message": "信息", "message": "信息",
@ -17,13 +17,12 @@
"URL": "链接", "URL": "链接",
"apiKey": "API 密钥", "apiKey": "API 密钥",
"errorInvalidUserPass": "无效的用户名/密码。", "errorInvalidUserPass": "无效的用户名/密码。",
"errorNotAdmin": "用户没有权限管理服务器。", "errorNotAdmin": "此用户不允许管理服务。",
"errorUserDisabled": "此永固可能已被禁用。", "errorUserDisabled": "此永固可能已被禁用。",
"error404": "404请检查内部URL。", "error404": "404请检查内部URL。",
"errorConnectionRefused": "连接被拒绝。", "errorConnectionRefused": "连接被拒绝。",
"error": "错误", "error": "错误",
"errorUnknown": "未知错误,请检查应用程序日志。", "errorUnknown": "未知错误,请检查应用程序日志。"
"errorProxy": "代理配置无效。"
}, },
"startPage": { "startPage": {
"welcome": "欢迎!", "welcome": "欢迎!",
@ -71,8 +70,7 @@
"adminOnly": "仅允许管理员账户(推荐)", "adminOnly": "仅允许管理员账户(推荐)",
"emailNotice": "您的电子邮件地址可以用来接收通知。", "emailNotice": "您的电子邮件地址可以用来接收通知。",
"allowAllDescription": "不建议,您应该允许单个用户在设置后登录。", "allowAllDescription": "不建议,您应该允许单个用户在设置后登录。",
"allowAll": "允许所有Jellyfin用户登录", "allowAll": "允许所有Jellyfin用户登录"
"authorizeManualUserPageNotice": "使用此选项将禁用“用户页面”功能。"
}, },
"jellyfinEmby": { "jellyfinEmby": {
"title": "Jellyfin/Emby", "title": "Jellyfin/Emby",
@ -128,8 +126,7 @@
"resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。", "resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。",
"resetLinksLanguage": "默认重置链接语言", "resetLinksLanguage": "默认重置链接语言",
"setPassword": "通过链接设置密码", "setPassword": "通过链接设置密码",
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。", "setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。"
"resetLinksRequiredForUserPage": "对于用户页面上的自助密码重置,这是必需的。"
}, },
"passwordValidation": { "passwordValidation": {
"title": "密码验证", "title": "密码验证",
@ -151,17 +148,5 @@
"successMessageNotice": "在用户创建账户时显示。", "successMessageNotice": "在用户创建账户时显示。",
"emailMessage": "电子邮件", "emailMessage": "电子邮件",
"emailMessageNotice": "显示在电子邮件的底部。" "emailMessageNotice": "显示在电子邮件的底部。"
},
"proxy": {
"description": "让jfa-go通过HTTP/SOCKS5代理进行所有连接。连接到Jellyfin将通过此代理进行测试。",
"title": "代理",
"protocol": "协议",
"address": "地址(包括端口)"
},
"userPage": {
"description": "用户页面(显示为“我的帐户”)允许用户访问有关他们帐户的信息,如其联系方式和账户过期日期。他们还可以更改密码、启动密码重置,并在无需询问您的情况下链接/更改联系方式。此外用户还可以在登录前后看到自定义的Markdown消息。",
"title": "用户页面",
"customizeMessages": "在设置中,单击“用户页面”旁边的编辑按钮以稍后进行设置。",
"requiredSettings": "通过Jellyfin登录到jfa-go必须设置。确保稍后选择“通过链接重置密码”以进行自助密码重置。"
} }
} }

View File

@ -11,8 +11,6 @@
"discordStartMessage": "您好!\n请输入 `/pin <PIN码>`以验证您的账户。", "discordStartMessage": "您好!\n请输入 `/pin <PIN码>`以验证您的账户。",
"languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。", "languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。",
"languageSet": "语言改成 {language}。", "languageSet": "语言改成 {language}。",
"discordDMs": "请检查您的DM找回答。", "discordDMs": "请检查您的DM找回答。"
"sentInvite": "已发送邀请。",
"sentInviteFailure": "发送邀请失败,请检查日志。"
} }
} }

14
main.go
View File

@ -46,8 +46,6 @@ var (
SWAGGER *bool SWAGGER *bool
QUIT = false QUIT = false
RUNNING = false RUNNING = false
LOGIP = false // Log admin IPs
LOGIPU = false // Log user IPs
// Used to know how many times to re-broadcast restart signal. // Used to know how many times to re-broadcast restart signal.
RESTARTLISTENERCOUNT = 0 RESTARTLISTENERCOUNT = 0
warning = color.New(color.FgYellow).SprintfFunc() warning = color.New(color.FgYellow).SprintfFunc()
@ -122,7 +120,6 @@ type appContext struct {
proxyTransport *http.Transport proxyTransport *http.Transport
proxyConfig easyproxy.ProxyConfig proxyConfig easyproxy.ProxyConfig
internalPWRs map[string]InternalPWR internalPWRs map[string]InternalPWR
pwrCaptchas map[string]Captcha
ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request ConfirmationKeys map[string]map[string]newUserDTO // Map of invite code to jwt to request
confirmationKeysLock sync.Mutex confirmationKeysLock sync.Mutex
} }
@ -557,16 +554,7 @@ func start(asDaemon, firstCall bool) {
cert := app.config.Section("advanced").Key("tls_cert").MustString("") cert := app.config.Section("advanced").Key("tls_cert").MustString("")
key := app.config.Section("advanced").Key("tls_key").MustString("") key := app.config.Section("advanced").Key("tls_key").MustString("")
if err := SRV.ListenAndServeTLS(cert, key); err != nil { if err := SRV.ListenAndServeTLS(cert, key); err != nil {
filesToCheck := []string{cert, key} app.err.Printf("Failure serving: %s", err)
fileNames := []string{"Certificate", "Key"}
for i, v := range filesToCheck {
_, err := os.Stat(v)
if err != nil {
app.err.Printf("SSL/TLS %s: %v\n", fileNames[i], err)
}
}
app.err.Fatalf("Failure serving with SSL/TLS: %s", err)
} }
} else { } else {
if err := SRV.ListenAndServe(); err != nil { if err := SRV.ListenAndServe(); err != nil {

View File

@ -332,9 +332,8 @@ type MatrixLoginDTO struct {
} }
type ResetPasswordDTO struct { type ResetPasswordDTO struct {
PIN string `json:"pin"` PIN string `json:"pin"`
Password string `json:"password"` Password string `json:"password"`
CaptchaText string `json:"captcha_text"`
} }
type AdminPasswordResetDTO struct { type AdminPasswordResetDTO struct {
@ -445,7 +444,6 @@ type ActivityDTO struct {
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
Value string `json:"value"` Value string `json:"value"`
Time int64 `json:"time"` Time int64 `json:"time"`
IP string `json:"ip"`
} }
type GetActivitiesDTO struct { type GetActivitiesDTO struct {

View File

@ -7,11 +7,11 @@ all:
cp -r ts tempts cp -r ts tempts
../scripts/dark-variant.sh tempts ../scripts/dark-variant.sh tempts
npx esbuild --target=es6 --bundle tempts/main.ts --outfile=out/main.js --minify npx esbuild --target=es6 --bundle tempts/main.ts --outfile=out/main.js --minify
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --external:../fonts/hanken* --minify npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:modal.css --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd .. cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd ..
mv out/_remixicon.css out/remixicon.css mv out/_remixicon.css out/remixicon.css
cp -r ../static/* out/ cp ../static/* out/
node inject.js node inject.js
debug: debug:
@ -22,10 +22,10 @@ debug:
-rm -r tempts -rm -r tempts
cp -r ts tempts cp -r ts tempts
../scripts/dark-variant.sh tempts ../scripts/dark-variant.sh tempts
npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --external:../fonts/hanken* --minify npx esbuild --bundle base.css --outfile=out/bundle.css --external:remixicon.css --minify
npx esbuild --target=es6 --bundle ts/main.ts --sourcemap --outfile=out/main.js --minify npx esbuild --target=es6 --bundle ts/main.ts --sourcemap --outfile=out/main.js --minify
npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css npx tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cp -r ../static/* out/ cp ../static/* out/
monitor: monitor:
npx live-server --watch=out --open=out/index.html & npx live-server --watch=out --open=out/index.html &

View File

@ -21,8 +21,7 @@
<div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card"> <div class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card">
<span class="heading"> Debian/Ubuntu (apt)</span> <span class="heading"> Debian/Ubuntu (apt)</span>
<div class="mt-1"> <div class="mt-1">
<pre style="margin: 0; line-height: 125%">sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg <pre style="margin: 0; line-height: 125%">curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
echo <span style="color: #aa5500">&quot;deb https://apt.hrfee.dev trusty<span id="deb-unstable" class="unfocused">-unstable</span> main&quot;</span> | sudo tee /etc/apt/sources.list.d/hrfee.list echo <span style="color: #aa5500">&quot;deb https://apt.hrfee.dev trusty<span id="deb-unstable" class="unfocused">-unstable</span> main&quot;</span> | sudo tee /etc/apt/sources.list.d/hrfee.list
@ -130,7 +129,7 @@ sudo apt-get install jfa-go-tray
<a class="button ~info mr-2 mb-2 lang-link" target="_blank" href="https://aur.archlinux.org/packages/jfa-go-git">arch (aur git)</a> <a class="button ~info mr-2 mb-2 lang-link" target="_blank" href="https://aur.archlinux.org/packages/jfa-go-git">arch (aur git)</a>
</div> </div>
</div> </div>
<section class="section ~neutral banner footer flex flex-row justify-between middle"> <section class="section ~neutral banner footer flex-expand middle">
<a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE" class="support">© 2023 Harvey Tindall</a> <a href="https://github.com/hrfee/jfa-go/blob/main/LICENSE" class="support">© 2023 Harvey Tindall</a>
</section> </section>
</div> </div>

View File

@ -10,7 +10,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/logger" "github.com/hrfee/jfa-go/logger"
"github.com/hrfee/mediabrowser" "github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4" "github.com/timshannon/badgerhold/v4"
@ -56,7 +55,6 @@ type Activity struct {
InviteCode string // Set for ActivityCreation, create/deleteInvite InviteCode string // Set for ActivityCreation, create/deleteInvite
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username. Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
Time time.Time Time time.Time
IP string
} }
type UserExpiry struct { type UserExpiry struct {
@ -565,12 +563,8 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) {
} }
// SetActivityKey stores value v in key k. // SetActivityKey stores value v in key k.
// If the IP should be logged, pass "gc", and whether or not the action is of a user func (st *Storage) SetActivityKey(k string, v Activity) {
func (st *Storage) SetActivityKey(k string, v Activity, gc *gin.Context, user bool) {
v.ID = k v.ID = k
if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) {
v.IP = gc.ClientIP()
}
err := st.db.Upsert(k, v) err := st.db.Upsert(k, v)
if err != nil { if err != nil {
// fmt.Printf("Failed to set custom content: %v\n", err) // fmt.Printf("Failed to set custom content: %v\n", err)

View File

@ -7,7 +7,7 @@ import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js"; import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js"; import { activityList } from "./modules/activity.js";
import { ProfileEditor } from "./modules/profiles.js"; import { ProfileEditor } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js"; import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
import { Updater } from "./modules/update.js"; import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js"; import { Login } from "./modules/login.js";
@ -216,8 +216,6 @@ login.onLogin = () => {
} }
} }
bindManualDropdowns();
login.bindLogout(document.getElementById("logout-button")); login.bindLogout(document.getElementById("logout-button"));
login.login("", ""); login.login("", "");

View File

@ -4,7 +4,6 @@ import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from
import { loadLangSelector } from "./modules/lang.js"; import { loadLangSelector } from "./modules/lang.js";
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js"; import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window { interface formWindow extends Window {
invalidPassword: string; invalidPassword: string;
@ -173,7 +172,35 @@ if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameFie
const passwordField = document.getElementById("create-password") as HTMLInputElement; const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement; const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
let captcha = new Captcha(window.code, window.captcha, window.reCAPTCHA, false); let captchaVerified = false;
let captchaID = "";
let captchaInput = document.getElementById("captcha-input") as HTMLInputElement;
const captchaCheckbox = document.getElementById("captcha-success") as HTMLSpanElement;
let prevCaptcha = "";
let baseValidator = (oncomplete: (valid: boolean) => void): void => {
if (window.captcha && !window.reCAPTCHA && (captchaInput.value != prevCaptcha)) {
prevCaptcha = captchaInput.value;
_post("/captcha/verify/" + window.code + "/" + captchaID + "/" + captchaInput.value, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
captchaCheckbox.innerHTML = `<i class="ri-check-line"></i>`;
captchaCheckbox.classList.add("~positive");
captchaCheckbox.classList.remove("~critical");
captchaVerified = true;
} else {
captchaCheckbox.innerHTML = `<i class="ri-close-line"></i>`;
captchaCheckbox.classList.add("~critical");
captchaCheckbox.classList.remove("~positive");
captchaVerified = false;
}
_baseValidator(oncomplete, captchaVerified);
}
});
} else {
_baseValidator(oncomplete, captchaVerified);
}
}
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void { function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.emailRequired) { if (window.emailRequired) {
@ -201,9 +228,20 @@ function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: bool
oncomplete(true); oncomplete(true);
} }
let baseValidator = captcha.baseValidatorWrapper(_baseValidator); interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}
declare var grecaptcha: GreCAPTCHA; declare var grecaptcha: GreCAPTCHA
let validatorConf: ValidatorConf = { let validatorConf: ValidatorConf = {
passwordField: passwordField, passwordField: passwordField,
@ -235,15 +273,29 @@ interface sendDTO {
captcha_text?: string; captcha_text?: string;
} }
const genCaptcha = () => {
_get("/captcha/gen/"+window.code, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
captchaID = req.response["id"];
document.getElementById("captcha-img").innerHTML = `
<img class="w-100" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${window.code}/${captchaID}"></img>
`;
captchaInput.value = "";
}
}
});
};
if (window.captcha && !window.reCAPTCHA) { if (window.captcha && !window.reCAPTCHA) {
captcha.generate(); genCaptcha();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate; (document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
captcha.input.onkeyup = validator.validate; captchaInput.onkeyup = validator.validate;
} }
const create = (event: SubmitEvent) => { const create = (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
if (window.captcha && !window.reCAPTCHA && !captcha.verified) { if (window.captcha && !window.reCAPTCHA && !captchaVerified) {
} }
addLoader(submitSpan); addLoader(submitSpan);
@ -278,8 +330,8 @@ const create = (event: SubmitEvent) => {
if (window.reCAPTCHA) { if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse(); send.captcha_text = grecaptcha.getResponse();
} else { } else {
send.captcha_id = captcha.captchaID; send.captcha_id = captchaID;
send.captcha_text = captcha.input.value; send.captcha_text = captchaInput.value;
} }
} }
_post("/newUser", send, (req: XMLHttpRequest) => { _post("/newUser", send, (req: XMLHttpRequest) => {

View File

@ -188,7 +188,7 @@ class user implements User, SearchableItem {
if (!telegram && !discord && !matrix && !email) return; if (!telegram && !discord && !matrix && !email) return;
let innerHTML = ` let innerHTML = `
<i class="icon ri-settings-2-line ml-2 dropdown-button"></i> <i class="icon ri-settings-2-line ml-2 dropdown-button"></i>
<div class="dropdown manual"> <div class="dropdown over-top manual">
<div class="dropdown-display lg"> <div class="dropdown-display lg">
<div class="card ~neutral @low"> <div class="card ~neutral @low">
<div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div> <div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div>
@ -218,13 +218,13 @@ class user implements User, SearchableItem {
</div> </div>
<div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div> <div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div>
<div class="accounts-unlink-telegram"> <div class="accounts-unlink-telegram">
<button class="button ~critical mb-2 w-full">Telegram</button> <button class="button ~critical mb-2 w-100">Telegram</button>
</div> </div>
<div class="accounts-unlink-discord"> <div class="accounts-unlink-discord">
<button class="button ~critical mb-2 w-full">Discord</button> <button class="button ~critical mb-2 w-100">Discord</button>
</div> </div>
<div class="accounts-unlink-matrix"> <div class="accounts-unlink-matrix">
<button class="button ~critical mb-2 w-full">Matrix</button> <button class="button ~critical mb-2 w-100">Matrix</button>
</div> </div>
</div> </div>
</div> </div>
@ -1271,7 +1271,7 @@ export class accountsList {
dList.textContent = ''; dList.textContent = '';
for (let name of list) { for (let name of list) {
const el = document.createElement("div") as HTMLDivElement; const el = document.createElement("div") as HTMLDivElement;
el.classList.add("flex", "flex-row", "justify-between", "truncate", "mt-2"); el.classList.add("flex-expand", "ellipsis", "mt-2");
el.innerHTML = ` el.innerHTML = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-4 accounts-announce-template-delete">&times;</span> <span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-4 accounts-announce-template-delete">&times;</span>
`; `;
@ -1634,7 +1634,6 @@ export class accountsList {
_displayExpiryDate = () => { _displayExpiryDate = () => {
let date: Date; let date: Date;
let invalid = false; let invalid = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) { if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date; date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any); invalid = "invalid" in (date as any);
@ -1646,7 +1645,7 @@ export class accountsList {
document.getElementById("extend-expiry-minutes") as HTMLSelectElement document.getElementById("extend-expiry-minutes") as HTMLSelectElement
]; ];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0"; invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = users.length > 0 ? users[0] : ""; let id = this._collectUsers().length == 1 ? this._collectUsers()[0] : "";
if (!id) invalid = true; if (!id) invalid = true;
else { else {
date = new Date(this._users[id].expiry*1000); date = new Date(this._users[id].expiry*1000);
@ -1666,12 +1665,7 @@ export class accountsList {
} else { } else {
submit.disabled = false; submit.disabled = false;
submitSpan.classList.remove("opacity-60"); submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.innerHTML = ` this._extendExpiryDate.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused"); this._extendExpiryDate.classList.remove("unfocused");
} }
} }
@ -1746,9 +1740,6 @@ export class accountsList {
} }
} }
this._extendExpiryTextInput.value = ""; this._extendExpiryTextInput.value = "";
this._usingExtendExpiryTextInput = false;
this._extendExpiryDate.classList.add("unfocused");
this._displayExpiryDate();
window.modals.extendExpiry.show(); window.modals.extendExpiry.show();
} }

View File

@ -14,7 +14,6 @@ export interface activity {
time: number; time: number;
username: string; username: string;
source_username: string; source_username: string;
ip: string;
} }
var activityTypeMoods = { var activityTypeMoods = {
@ -44,7 +43,6 @@ export class Activity implements activity, SearchableItem {
private _referrer: HTMLElement; private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement; private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement; private _delete: HTMLElement;
private _ip: HTMLElement;
private _act: activity; private _act: activity;
private _urlBase: string = ((): string => { private _urlBase: string = ((): string => {
let link = window.location.href; let link = window.location.href;
@ -207,16 +205,6 @@ export class Activity implements activity, SearchableItem {
} }
} }
get ip(): string { return this._act.ip; }
set ip(v: string) {
this._act.ip = v;
if (v) {
this._ip.innerHTML = `<span class="supra mr-2">IP</span><span class="font-mono bg-inherit">${v}</span>`;
} else {
this._ip.textContent = ``;
}
}
get invite_code(): string { return this._act.invite_code; } get invite_code(): string { return this._act.invite_code; }
set invite_code(v: string) { set invite_code(v: string) {
this._act.invite_code = v; this._act.invite_code = v;
@ -272,13 +260,12 @@ export class Activity implements activity, SearchableItem {
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span> <span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
</div> </div>
</div> </div>
<div class="flex flex-row justify-between items-end"> <div class="flex flex-col md:flex-row justify-between">
<div class="flex flex-col md:flex-row gap-2"> <div>
<div> <span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span> </div>
</div> <div>
<span class="content activity-referrer"></span> <span class="content activity-referrer"></span>
<span class="content activity-ip"></span>
</div> </div>
<div> <div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button> <button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
@ -290,7 +277,6 @@ export class Activity implements activity, SearchableItem {
this._time = this._card.querySelector(".activity-time"); this._time = this._card.querySelector(".activity-time");
this._sourceType = this._card.querySelector(".activity-source-type"); this._sourceType = this._card.querySelector(".activity-source-type");
this._source = this._card.querySelector(".activity-source"); this._source = this._card.querySelector(".activity-source");
this._ip = this._card.querySelector(".activity-ip");
this._referrer = this._card.querySelector(".activity-referrer"); this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type"); this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete"); this._delete = this._card.querySelector(".activity-delete");
@ -338,7 +324,6 @@ export class Activity implements activity, SearchableItem {
this.source = act.source; this.source = act.source;
this.value = act.value; this.value = act.value;
this.type = act.type; this.type = act.type;
this.ip = act.ip;
} }
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => { delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {

View File

@ -1,81 +0,0 @@
import { _get, _post } from "./common.js";
export class Captcha {
isPWR = false;
enabled = true;
verified = false;
captchaID = "";
input = document.getElementById("captcha-input") as HTMLInputElement;
checkbox = document.getElementById("captcha-success") as HTMLSpanElement;
previous = "";
reCAPTCHA = false;
code = "";
get value(): string { return this.input.value; }
hasChanged = (): boolean => { return this.value != this.previous; }
baseValidatorWrapper = (_baseValidator: (oncomplete: (valid: boolean) => void, captchaValid: boolean) => void) => {
return (oncomplete: (valid: boolean) => void): void => {
if (this.enabled && !this.reCAPTCHA && this.hasChanged()) {
this.previous = this.value;
this.verify(() => {
_baseValidator(oncomplete, this.verified);
});
} else {
_baseValidator(oncomplete, this.verified);
}
};
};
verify = (callback: () => void) => _post("/captcha/verify/" + this.code + "/" + this.captchaID + "/" + this.input.value + (this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 204) {
this.checkbox.innerHTML = `<i class="ri-check-line"></i>`;
this.checkbox.classList.add("~positive");
this.checkbox.classList.remove("~critical");
this.verified = true;
} else {
this.checkbox.innerHTML = `<i class="ri-close-line"></i>`;
this.checkbox.classList.add("~critical");
this.checkbox.classList.remove("~positive");
this.verified = false;
}
callback();
}
});
generate = () => _get("/captcha/gen/"+this.code+(this.isPWR ? "?pwr=true" : ""), null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status == 200) {
this.captchaID = this.isPWR ? this.code : req.response["id"];
// the Math.random() appearance below is used for PWRs, since they don't have a unique captchaID. The parameter is ignored by the server, but tells the browser to reload the image.
document.getElementById("captcha-img").innerHTML = `
<img class="w-full" src="${window.location.toString().substring(0, window.location.toString().lastIndexOf("/invite"))}/captcha/img/${this.code}/${this.isPWR ? Math.random() : this.captchaID}${this.isPWR ? "?pwr=true" : ""}"></img>
`;
this.input.value = "";
}
}
});
constructor(code: string, enabled: boolean, reCAPTCHA: boolean, isPWR: boolean) {
this.code = code;
this.enabled = enabled;
this.reCAPTCHA = reCAPTCHA;
this.isPWR = isPWR;
}
}
export interface GreCAPTCHA {
render: (container: HTMLDivElement, parameters: {
sitekey?: string,
theme?: string,
size?: string,
tabindex?: number,
"callback"?: () => void,
"expired-callback"?: () => void,
"error-callback"?: () => void
}) => void;
getResponse: (opt_widget_id?: HTMLDivElement) => string;
}

View File

@ -263,31 +263,3 @@ export function insertText(textarea: HTMLTextAreaElement, text: string) {
textarea.focus(); textarea.focus();
} }
} }
export function bindManualDropdowns() {
const buttons = Array.from(document.getElementsByClassName("dropdown-manual-toggle") as HTMLCollectionOf<HTMLSpanElement>);
for (let button of buttons) {
const parent = button.closest(".dropdown.manual");
const display = parent.querySelector(".dropdown-display");
const mousein = () => parent.classList.add("selected");
const mouseout = () => parent.classList.remove("selected");
button.addEventListener("mouseover", mousein);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseover", mousein);
display.addEventListener("mouseout", mouseout);
button.onclick = () => {
parent.classList.add("selected");
document.addEventListener("click", outerClickListener);
button.removeEventListener("mouseout", mouseout);
display.removeEventListener("mouseout", mouseout);
};
const outerClickListener = (event: Event) => {
if (!(event.target instanceof HTMLElement && (display.contains(event.target) || button.contains(event.target)))) {
parent.classList.remove("selected");
document.removeEventListener("click", outerClickListener);
button.addEventListener("mouseout", mouseout);
display.addEventListener("mouseout", mouseout);
}
};
}
}

View File

@ -78,7 +78,7 @@ class DOMInvite implements Invite {
get expiresIn(): string { return this._expiresIn } get expiresIn(): string { return this._expiresIn }
set expiresIn(expiry: string) { set expiresIn(expiry: string) {
this._expiresIn = expiry; this._expiresIn = expiry;
this._codeArea.querySelector("span.inv-duration").textContent = expiry; this._infoArea.querySelector("span.inv-duration").textContent = expiry;
} }
private _userExpiry: string; private _userExpiry: string;
@ -110,15 +110,15 @@ class DOMInvite implements Invite {
const chip = container.querySelector("span.inv-email-chip"); const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement; const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (address == "") { if (address == "") {
container.classList.remove("mr-4");
icon.classList.remove("ri-mail-line"); icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line"); icon.classList.remove("ri-mail-close-line");
chip.classList.remove("~neutral"); chip.classList.remove("~neutral");
chip.classList.remove("~critical"); chip.classList.remove("~critical");
chip.classList.remove("button"); chip.classList.remove("chip");
chip.parentElement.classList.remove("h-full");
} else { } else {
chip.classList.add("button"); container.classList.add("mr-4");
chip.parentElement.classList.add("h-full"); chip.classList.add("chip");
if (address.includes("Failed")) { if (address.includes("Failed")) {
icon.classList.remove("ri-mail-line"); icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line"); icon.classList.add("ri-mail-close-line");
@ -146,11 +146,10 @@ class DOMInvite implements Invite {
} }
this._right.classList.remove("empty"); this._right.classList.remove("empty");
let innerHTML = ` let innerHTML = `
<table class="table inv-table table-p-0"> <table class="table inv-table">
<thead> <thead>
<tr> <tr>
<th>${window.lang.strings("name")}</th> <th>${window.lang.strings("name")}</th>
<th class="w-2"></th>
<th>${window.lang.strings("date")}</th> <th>${window.lang.strings("date")}</th>
</tr> </tr>
</thead> </thead>
@ -160,7 +159,6 @@ class DOMInvite implements Invite {
innerHTML += ` innerHTML += `
<tr> <tr>
<td>${username}</td> <td>${username}</td>
<td class="w-2"></td>
<td>${toDateString(new Date(uB[username] * 1000))}</td> <td>${toDateString(new Date(uB[username] * 1000))}</td>
</tr> </tr>
`; `;
@ -268,21 +266,18 @@ class DOMInvite implements Invite {
constructor(invite: Invite) { constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data. // first create the invite structure, then use our setter methods to fill in the data.
this._container = document.createElement('div') as HTMLDivElement; this._container = document.createElement('div') as HTMLDivElement;
this._container.classList.add("inv", "overflow-visible"); this._container.classList.add("inv");
this._header = document.createElement('div') as HTMLDivElement; this._header = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._header); this._container.appendChild(this._header);
this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "flex", "flex-row", "justify-between", "mt-2", "overflow-visible", "gap-2"); this._header.classList.add("card", "dark:~d_neutral", "@low", "inv-header", "elem-pad", "no-pad", "flex-expand", "row", "mt-2", "overflow-y");
this._codeArea = document.createElement('div') as HTMLDivElement; this._codeArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._codeArea); this._header.appendChild(this._codeArea);
this._codeArea.classList.add("flex", "flex-row", "flex-wrap", "justify-between", "w-full", "items-baseline", "gap-2", "truncate"); this._codeArea.classList.add("inv-codearea");
this._codeArea.innerHTML = ` this._codeArea.innerHTML = `
<div class="flex items-baseline gap-x-4 gap-y-2 truncate"> <a class="invite-link text-black dark:text-white font-mono bg-inherit mr-4" href=""></a>
<a class="invite-link text-black dark:text-white font-mono bg-inherit truncate" href=""></a> <span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
</div>
<span class="inv-duration"></span>
`; `;
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement; const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
copyButton.onclick = () => { copyButton.onclick = () => {
@ -302,15 +297,16 @@ class DOMInvite implements Invite {
this._infoArea = document.createElement('div') as HTMLDivElement; this._infoArea = document.createElement('div') as HTMLDivElement;
this._header.appendChild(this._infoArea); this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-baseline", "gap-2"); this._infoArea.classList.add("inv-infoarea");
this._infoArea.innerHTML = ` this._infoArea.innerHTML = `
<div class="tooltip below darker" tabindex="0"> <div class="tooltip left">
<span class="inv-email-chip h-full"><i></i></span> <span class="inv-email-chip"><i></i></span>
<span class="content sm p-1"></span> <span class="content sm"></span>
</div> </div>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span> <span class="inv-duration mr-4"></span>
<span class="button ~critical @low inv-delete">${window.lang.strings("delete")}</span>
<label> <label>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line not-rotated"></i> <i class="icon clickable ri-arrow-down-s-line not-rotated"></i>
<input class="inv-toggle-details unfocused" type="checkbox"> <input class="inv-toggle-details unfocused" type="checkbox">
</label> </label>
`; `;
@ -319,30 +315,25 @@ class DOMInvite implements Invite {
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement); const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
toggle.onchange = () => { this.expanded = !this.expanded; }; toggle.onchange = () => { this.expanded = !this.expanded; };
const toggleDetails = (event: Event) => { this._header.onclick = (event: Event) => {
if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) { if (event.target == this._header) {
this.expanded = !this.expanded; this.expanded = !this.expanded;
} }
}; };
this._header.onclick = toggleDetails;
this._details = document.createElement('div') as HTMLDivElement; this._details = document.createElement('div') as HTMLDivElement;
this._container.appendChild(this._details); this._container.appendChild(this._details);
this._details.classList.add("card", "~neutral", "@low", "mt-2", "inv-details"); this._details.classList.add("card", "~neutral", "@low", "mt-2", "no-pad", "inv-details");
const detailsInner = document.createElement('div') as HTMLDivElement; const detailsInner = document.createElement('div') as HTMLDivElement;
this._details.appendChild(detailsInner); this._details.appendChild(detailsInner);
detailsInner.classList.add("inv-row", "flex", "flex-row", "flex-wrap", "justify-between", "gap-4"); detailsInner.classList.add("inv-row", "flex-expand", "row", "elem-pad", "align-top");
this._left = document.createElement('div') as HTMLDivElement; this._left = document.createElement('div') as HTMLDivElement;
this._left.classList.add("flex", "flex-row", "flex-wrap", "gap-4", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
detailsInner.appendChild(this._left); detailsInner.appendChild(this._left);
const leftLeft = document.createElement("div") as HTMLDivElement; this._left.classList.add("inv-profilearea");
this._left.appendChild(leftLeft);
leftLeft.classList.add("inv-profilearea", "min-w-full", "sm:min-w-fit");
let innerHTML = ` let innerHTML = `
<p class="supra mb-2 top">${window.lang.strings("profile")}</p> <p class="supra mb-2 top">${window.lang.strings("profile")}</p>
<div class="select ~neutral @low inv-profileselect min-w-full inline-block mb-2"> <div class="select ~neutral @low inv-profileselect inline-block mb-2">
<select> <select>
<option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option> <option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option>
</select> </select>
@ -361,7 +352,7 @@ class DOMInvite implements Invite {
</label> </label>
`; `;
} }
leftLeft.innerHTML = innerHTML; this._left.innerHTML = innerHTML;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile; (this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
if (window.notificationsEnabled) { if (window.notificationsEnabled) {
@ -373,21 +364,20 @@ class DOMInvite implements Invite {
} }
this._middle = document.createElement('div') as HTMLDivElement; this._middle = document.createElement('div') as HTMLDivElement;
this._left.appendChild(this._middle); detailsInner.appendChild(this._middle);
this._middle.classList.add("flex", "flex-col", "justify-between"); this._middle.classList.add("block");
this._middle.innerHTML = ` this._middle.innerHTML = `
<p class="supra top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p> <p class="supra mb-4 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p> <p class="supra mb-4">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p> <p class="supra mb-4"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></p>
<p class="flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p> <p class="mb-4 flex items-center"><span class="user-label-label supra mr-2"></span> <span class="user-label chip ~blue unfocused"></span></p>
`; `;
this._right = document.createElement('div') as HTMLDivElement; this._right = document.createElement('div') as HTMLDivElement;
detailsInner.appendChild(this._right); detailsInner.appendChild(this._right);
this._right.classList.add("card", "~neutral", "@low", "inv-created-users", "min-w-full", "sm:min-w-fit", "whitespace-nowrap"); this._right.classList.add("card", "~neutral", "@low", "inv-created-users");
this._right.innerHTML = `<span class="supra table-header">${window.lang.strings("inviteUsersCreated")}</span>`; this._right.innerHTML = `<strong class="supra table-header">${window.lang.strings("inviteUsersCreated")}</strong>`;
this._userTable = document.createElement('div') as HTMLDivElement; this._userTable = document.createElement('div') as HTMLDivElement;
this._userTable.classList.add("text-sm", "mt-1", );
this._right.appendChild(this._userTable); this._right.appendChild(this._userTable);
@ -480,8 +470,8 @@ export class inviteList implements inviteList {
this._list.classList.add("empty"); this._list.classList.add("empty");
this._list.innerHTML = ` this._list.innerHTML = `
<div class="inv inv-empty"> <div class="inv inv-empty">
<div class="card dark:~d_neutral @low inv-header mt-2"> <div class="card dark:~d_neutral @low inv-header flex-expand mt-2">
<div class="justify-start"> <div class="inv-codearea">
<span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span> <span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span>
</div> </div>
</div> </div>

View File

@ -83,7 +83,7 @@ export const loadLangSelector = (page: string) => {
let innerHTML = ''; let innerHTML = '';
for (let code in req.response) { for (let code in req.response) {
queryString.set("lang", code); queryString.set("lang", code);
innerHTML += `<a href="?${queryString.toString()}" class="button w-full text-left justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`; innerHTML += `<a href="?${queryString.toString()}" class="button w-100 al justify-start ~neutral mb-2 lang-link">${req.response[code]}</a>`;
queryString.delete("lang"); queryString.delete("lang");
} }
list.innerHTML = innerHTML; list.innerHTML = innerHTML;

View File

@ -86,7 +86,7 @@ class profile implements Profile {
<td><span class="button @low profile-referrals"></span></td> <td><span class="button @low profile-referrals"></span></td>
`; `;
innerHTML += ` innerHTML += `
<td class="profile-from truncate"></td> <td class="profile-from ellipsis"></td>
<td class="profile-libraries"></td> <td class="profile-libraries"></td>
<td><span class="button ~critical @low">${window.lang.strings("delete")}</span></td> <td><span class="button ~critical @low">${window.lang.strings("delete")}</span></td>
`; `;

View File

@ -311,7 +311,7 @@ export class Search {
} }
const container = document.createElement("span") as HTMLSpanElement; const container = document.createElement("span") as HTMLSpanElement;
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2", "align-bottom"); container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
container.innerHTML = ` container.innerHTML = `
<div class="flex flex-col mr-2"> <div class="flex flex-col mr-2">
<span>${query.name}</span> <span>${query.name}</span>

View File

@ -22,18 +22,13 @@ export class Tabs implements Tabs {
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => { switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
this._current = tabID; this._current = tabID;
let baseOffset = -1;
for (let t of this.tabs) { for (let t of this.tabs) {
if (baseOffset == -1) baseOffset = t.buttonEl.offsetLeft;
if (t.tabID == tabID) { if (t.tabID == tabID) {
t.buttonEl.classList.add("active", "~urge"); t.buttonEl.classList.add("active", "~urge");
if (t.preFunc && !noRun) { t.preFunc(); } if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused"); t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); } if (t.postFunc && !noRun) { t.postFunc(); }
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID })); document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
// t.buttonEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
t.buttonEl.parentElement.scrollTo(t.buttonEl.offsetLeft-baseOffset, 0);
} else { } else {
t.buttonEl.classList.remove("active"); t.buttonEl.classList.remove("active");
t.buttonEl.classList.remove("~urge"); t.buttonEl.classList.remove("~urge");

View File

@ -2,7 +2,6 @@ import { Modal } from "./modules/modal.js";
import { Validator, ValidatorConf } from "./modules/validator.js"; import { Validator, ValidatorConf } from "./modules/validator.js";
import { _post, addLoader, removeLoader } from "./modules/common.js"; import { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js"; import { loadLangSelector } from "./modules/lang.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window { interface formWindow extends Window {
invalidPassword: string; invalidPassword: string;
@ -29,10 +28,6 @@ interface formWindow extends Window {
userExpiryHours: number; userExpiryHours: number;
userExpiryMinutes: number; userExpiryMinutes: number;
userExpiryMessage: string; userExpiryMessage: string;
captcha: boolean;
reCAPTCHA: boolean;
reCAPTCHASiteKey: string;
pwrPIN: string;
} }
loadLangSelector("pwr"); loadLangSelector("pwr");
@ -47,26 +42,11 @@ const rePasswordField = document.getElementById("create-reenter-password") as HT
window.successModal = new Modal(document.getElementById("modal-success"), true); window.successModal = new Modal(document.getElementById("modal-success"), true);
function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: boolean): void {
if (window.captcha && !window.reCAPTCHA && !captchaValid) {
oncomplete(false);
return;
}
oncomplete(true);
}
let captcha = new Captcha(window.pwrPIN, window.captcha, window.reCAPTCHA, true);
declare var grecaptcha: GreCAPTCHA;
let baseValidator = captcha.baseValidatorWrapper(_baseValidator);
let validatorConf: ValidatorConf = { let validatorConf: ValidatorConf = {
passwordField: passwordField, passwordField: passwordField,
rePasswordField: rePasswordField, rePasswordField: rePasswordField,
submitInput: submitInput, submitInput: submitInput,
submitButton: submitSpan, submitButton: submitSpan
validatorFunc: baseValidator
}; };
var validator = new Validator(validatorConf); var validator = new Validator(validatorConf);
@ -75,13 +55,6 @@ var requirements = validator.requirements;
interface sendDTO { interface sendDTO {
pin: string; pin: string;
password: string; password: string;
captcha_text?: string;
}
if (window.captcha && !window.reCAPTCHA) {
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
} }
form.onsubmit = (event: Event) => { form.onsubmit = (event: Event) => {
@ -92,31 +65,12 @@ form.onsubmit = (event: Event) => {
pin: params.get("pin"), pin: params.get("pin"),
password: passwordField.value password: passwordField.value
}; };
if (window.captcha) {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_text = captcha.input.value;
}
}
_post("/reset", send, (req: XMLHttpRequest) => { _post("/reset", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
removeLoader(submitSpan); removeLoader(submitSpan);
if (req.status == 400) { if (req.status == 400) {
if (req.response["error"] as string) { for (let type in req.response) {
const old = submitSpan.textContent; if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
submitSpan.textContent = window.messages[req.response["error"]];
submitSpan.classList.add("~critical");
submitSpan.classList.remove("~urge");
setTimeout(() => {
submitSpan.classList.add("~urge");
submitSpan.classList.remove("~critical");
submitSpan.textContent = old;
}, 2000);
} else {
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
}
} }
return; return;
} else if (req.status != 200) { } else if (req.status != 200) {

View File

@ -144,9 +144,9 @@ class ContactMethods {
append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => { append = (name: string, details: MyDetailsContactMethod, icon: string, addEditFunc?: (add: boolean) => void, required?: boolean) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.classList.add("flex", "flex-row", "justify-between", "my-2", "flex-nowrap"); row.classList.add("flex", "flex-expand", "my-2", "flex-nowrap");
let innerHTML = ` let innerHTML = `
<div class="flex items-baseline flex-nowrap truncate"> <div class="flex items-baseline flex-nowrap ellipsis">
<span class="shield ~urge" alt="${name}"> <span class="shield ~urge" alt="${name}">
<span class="icon"> <span class="icon">
${icon} ${icon}
@ -637,10 +637,10 @@ document.addEventListener("details-reload", () => {
if (typeof(messageCard) != "undefined" && messageCard != null) { if (typeof(messageCard) != "undefined" && messageCard != null) {
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username); messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
// setBestRowSpan(messageCard, false); setBestRowSpan(messageCard, false);
// contactCard.querySelector(".content").classList.add("h-100"); // contactCard.querySelector(".content").classList.add("h-100");
} else if (!statusCard.classList.contains("unfocused")) { } else if (!statusCard.classList.contains("unfocused")) {
// setBestRowSpan(passwordCard, true); setBestRowSpan(passwordCard, true);
} }
if (window.referralsEnabled) { if (window.referralsEnabled) {
@ -649,69 +649,15 @@ document.addEventListener("details-reload", () => {
if (req.readyState != 4 || req.status != 200) return; if (req.readyState != 4 || req.status != 200) return;
const referral: MyReferral = req.response as MyReferral; const referral: MyReferral = req.response as MyReferral;
referralCard.update(referral); referralCard.update(referral);
setCardOrder(messageCard);
}); });
} else { } else {
referralCard.hide(); referralCard.hide();
setCardOrder(messageCard);
} }
} else {
setCardOrder(messageCard);
} }
} }
}); });
}); });
const setCardOrder = (messageCard: HTMLElement) => {
const cards = document.getElementById("user-cardlist");
const children = Array.from(cards.children);
const idxs = [...Array(cards.childElementCount).keys()]
// The message card is the first element and should always be so, so remove it from the list.
const hasMessageCard = !(typeof(messageCard) == "undefined" || messageCard == null);
if (hasMessageCard) idxs.shift();
const perms = generatePermutations(idxs);
let minHeight = 999999;
let minHeightPerm: [number[], number[]];
for (let perm of perms) {
let leftHeight = 0;
for (let idx of perm[0]) {
leftHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
if (hasMessageCard) leftHeight += (cards.children[0] as HTMLElement).offsetHeight;
let rightHeight = 0;
for (let idx of perm[1]) {
rightHeight += (cards.children[idx] as HTMLElement).offsetHeight;
}
let height = Math.max(leftHeight, rightHeight);
// console.log("got height", leftHeight, rightHeight, height, "for", perm);
if (height < minHeight) {
minHeight = height;
minHeightPerm = perm;
}
}
const gapDiv = () => {
const g = document.createElement("div");
g.classList.add("my-4");
return g;
};
let addValue = hasMessageCard ? 1 : 0;
// if (hasMessageCard) cards.appendChild(children[0]);
if (hasMessageCard) cards.appendChild(gapDiv());
for (let side of minHeightPerm) {
for (let i = 0; i < side.length; i++) {
// (cards.children[side[i]] as HTMLElement).style.order = (i+addValue).toString();
children[side[i]].remove();
cards.appendChild(children[side[i]]);
cards.appendChild(gapDiv());
}
// addValue += side.length;
}
console.log("Shortest order:", minHeightPerm);
};
const login = new Login(window.modals.login as Modal, "/my/", "opaque"); const login = new Login(window.modals.login as Modal, "/my/", "opaque");
login.onLogin = () => { login.onLogin = () => {
console.log("Logged in."); console.log("Logged in.");
@ -753,24 +699,6 @@ const computeRealHeight = (el: HTMLElement): number => {
return total; return total;
} }
const generatePermutations = (xs: number[]): [number[], number[]][] => {
const l = xs.length;
let out: [number[], number[]][] = [];
for (let i = 0; i < (l << 1); i++) {
let incl = [];
let excl = [];
for (let j = 0; j < l; j++) {
if (i & (1 << j)) {
incl.push(xs[j]);
} else {
excl.push(xs[j]);
}
}
out.push([incl, excl]);
}
return out;
}
login.bindLogout(document.getElementById("logout-button")); login.bindLogout(document.getElementById("logout-button"));
login.login("", ""); login.login("", "");

View File

@ -45,13 +45,13 @@ func (app *appContext) getUserTokenLogin(gc *gin.Context) {
respond(500, "Contact Admin", gc) respond(500, "Contact Admin", gc)
return return
} }
app.logIpInfo(gc, true, "UserToken requested (login attempt)") app.info.Println("UserToken requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, true) username, password, ok := app.decodeValidateLoginHeader(gc)
if !ok { if !ok {
return return
} }
user, ok := app.validateJellyfinCredentials(username, password, gc, true) user, ok := app.validateJellyfinCredentials(username, password, gc)
if !ok { if !ok {
return return
} }
@ -86,7 +86,7 @@ func (app *appContext) getUserTokenRefresh(gc *gin.Context) {
return return
} }
app.logIpInfo(gc, true, "UserToken request (refresh token)") app.info.Println("UserToken request (refresh token)")
claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh") claims, ok := app.decodeValidateRefreshCookie(gc, "user-refresh")
if !ok { if !ok {
return return

View File

@ -120,7 +120,7 @@ func (app *appContext) checkUsers() {
continue continue
} }
app.storage.SetActivityKey(shortuuid.New(), activity, nil, false) app.storage.SetActivityKey(shortuuid.New(), activity)
app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
app.jf.CacheExpiry = time.Now() app.jf.CacheExpiry = time.Now()

122
views.go
View File

@ -296,10 +296,6 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
data["telegramEnabled"] = false data["telegramEnabled"] = false
data["discordEnabled"] = false data["discordEnabled"] = false
data["matrixEnabled"] = false data["matrixEnabled"] = false
data["captcha"] = app.config.Section("captcha").Key("enabled").MustBool(false)
data["reCAPTCHA"] = app.config.Section("captcha").Key("recaptcha").MustBool(false)
data["reCAPTCHASiteKey"] = app.config.Section("captcha").Key("recaptcha_site_key").MustString("")
data["pwrPIN"] = pin
gcHTML(gc, http.StatusOK, "form-loader.html", data) gcHTML(gc, http.StatusOK, "form-loader.html", data)
return return
} }
@ -365,7 +361,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
SourceType: ActivityUser, SourceType: ActivityUser,
Source: jfUser.ID, Source: jfUser.ID,
Time: time.Now(), Time: time.Now(),
}, gc, true) })
} }
} }
@ -397,28 +393,20 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
// @Router /captcha/img/{code}/{captchaID} [get] // @Router /captcha/img/{code}/{captchaID} [get]
func (app *appContext) GetCaptcha(gc *gin.Context) { func (app *appContext) GetCaptcha(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
captchaID := gc.Param("captchaID") captchaID := gc.Param("captchaID")
var inv Invite inv, ok := app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
var capt Captcha var capt Captcha
ok := true ok = true
if !isPWR { if inv.Captchas != nil {
inv, ok = app.storage.GetInvitesKey(code) capt, ok = inv.Captchas[captchaID]
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
}
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
} else {
ok = false
}
} else {
capt, ok = app.pwrCaptchas[code]
} }
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
@ -437,13 +425,7 @@ func (app *appContext) GetCaptcha(gc *gin.Context) {
// @tags Users // @tags Users
func (app *appContext) GenCaptcha(gc *gin.Context) { func (app *appContext) GenCaptcha(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true" inv, ok := app.storage.GetInvitesKey(code)
var inv Invite
ok := true
if !isPWR {
inv, ok = app.storage.GetInvitesKey(code)
}
if !ok { if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{ gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc), "urlBase": app.getURLBase(gc),
@ -458,7 +440,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if !isPWR && inv.Captchas == nil { if inv.Captchas == nil {
inv.Captchas = map[string]Captcha{} inv.Captchas = map[string]Captcha{}
} }
captchaID := genAuthToken() captchaID := genAuthToken()
@ -468,43 +450,26 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc) respondBool(500, false, gc)
return return
} }
if isPWR { inv.Captchas[captchaID] = Captcha{
if app.pwrCaptchas == nil { Answer: capt.Text,
app.pwrCaptchas = map[string]Captcha{} Image: buf.Bytes(),
} Generated: time.Now(),
app.pwrCaptchas[code] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
}
} else {
inv.Captchas[captchaID] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
}
app.storage.SetInvitesKey(code, inv)
} }
app.storage.SetInvitesKey(code, inv)
gc.JSON(200, genCaptchaDTO{captchaID}) gc.JSON(200, genCaptchaDTO{captchaID})
return return
} }
func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool { func (app *appContext) verifyCaptcha(code, id, text string) bool {
reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false) reCAPTCHA := app.config.Section("captcha").Key("recaptcha").MustBool(false)
if !reCAPTCHA { if !reCAPTCHA {
// internal CAPTCHA // internal CAPTCHA
var c Captcha inv, ok := app.storage.GetInvitesKey(code)
ok := true if !ok || inv.Captchas == nil {
if !isPWR { app.debug.Printf("Couldn't find invite \"%s\"", code)
inv, ok := app.storage.GetInvitesKey(code) return false
if !ok || (!isPWR && inv.Captchas == nil) {
app.debug.Printf("Couldn't find invite \"%s\"", code)
return false
}
c, ok = inv.Captchas[id]
} else {
c, ok = app.pwrCaptchas[code]
} }
c, ok := inv.Captchas[id]
if !ok { if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id) app.debug.Printf("Couldn't find Captcha \"%s\"", id)
return false return false
@ -564,30 +529,21 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
// @Router /captcha/verify/{code}/{captchaID}/{text} [get] // @Router /captcha/verify/{code}/{captchaID}/{text} [get]
func (app *appContext) VerifyCaptcha(gc *gin.Context) { func (app *appContext) VerifyCaptcha(gc *gin.Context) {
code := gc.Param("invCode") code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
captchaID := gc.Param("captchaID") captchaID := gc.Param("captchaID")
text := gc.Param("text") text := gc.Param("text")
var inv Invite inv, ok := app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
var capt Captcha var capt Captcha
var ok bool if inv.Captchas != nil {
if !isPWR { capt, ok = inv.Captchas[captchaID]
inv, ok = app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
"cssClass": app.cssClass,
"cssVersion": cssVersion,
"contactMessage": app.config.Section("ui").Key("contact_message").String(),
})
return
}
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
} else {
ok = false
}
} else {
capt, ok = app.pwrCaptchas[code]
} }
if !ok { if !ok {
respondBool(400, false, gc) respondBool(400, false, gc)
@ -655,7 +611,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
app.debug.Printf("Invalid key") app.debug.Printf("Invalid key")
return return
} }
f, success := app.newUser(req, true, gc) f, success := app.newUser(req, true)
if !success { if !success {
app.err.Printf("Failed to create new user") app.err.Printf("Failed to create new user")
// Not meant for us. Calling this will be a mess, but at least it might give us some information. // Not meant for us. Calling this will be a mess, but at least it might give us some information.