1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-06-17 06:57:47 +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:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
settings:
host:
from_secret: ssh2_host
@ -52,8 +55,9 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
key:
from_secret: ssh2_key
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /mnt/buildx/jfa-go/build.sh stable
@ -124,6 +128,9 @@ type: docker
steps:
- name: build-deploy
image: appleboy/drone-ssh
volumes:
- name: ssh_key
path: /root/drone_rsa
environment:
BUILDRONE_KEY:
from_secret: BUILDRONE_KEY
@ -134,10 +141,11 @@ steps:
from_secret: ssh2_username
port:
from_secret: ssh2_port
volumes:
- /root/.ssh/docker-build:/root/drone_rsa
envs:
- buildrone_key
key:
from_secret: ssh2_key
key_path: /root/drone_rsa
command_timeout: 50m
script:
- /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).
#### 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
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,
Value: act.Value,
Time: act.Time.Unix(),
IP: act.IP,
}
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
resp.Activities[i].Username = act.Value

View File

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

View File

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

View File

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

View File

@ -55,7 +55,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(),
}, gc, false)
})
profile := app.storage.GetDefaultProfile()
if req.Profile != "" && req.Profile != "none" {
@ -114,7 +114,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
type errorFunc func(gc *gin.Context)
// 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)
if existingUser.Name != "" {
f = func(gc *gin.Context) {
@ -331,7 +331,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool, gc *gin.Context)
InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(),
}, gc, true)
})
emailStore := EmailAddress{
Addr: req.Email,
@ -503,7 +503,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
var req newUserDTO
gc.BindJSON(&req)
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)
respond(400, "errorCaptcha", gc)
return
@ -539,7 +539,7 @@ func (app *appContext) NewUser(gc *gin.Context) {
return
}
}
f, success := app.newUser(req, false, gc)
f, success := app.newUser(req, false)
if !success {
f(gc)
return
@ -609,7 +609,7 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
SourceType: ActivityAdmin,
Source: gc.GetString("jfId"),
Time: time.Now(),
}, gc, false)
})
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@ -687,7 +687,7 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(),
}, gc, false)
})
if sendMail && req.Notify {
if err := app.sendByID(msg, userID); err != nil {
@ -1208,7 +1208,7 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
Source: gc.GetString("jfId"),
Value: "email",
Time: time.Now(),
}, gc, false)
})
if ombiEnabled {
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
gc.BindJSON(&req)
validation := app.validator.validate(req.Password)
captcha := app.config.Section("captcha").Key("enabled").MustBool(false)
valid := true
for _, val := range validation {
if !val {
@ -122,18 +121,12 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
}
if !valid || req.PIN == "" {
// 200 bcs idk what i did in js
app.info.Printf("%s: Password reset failed: Invalid password", req.PIN)
gc.JSON(400, validation)
return
}
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
if reset, ok := app.internalPWRs[req.PIN]; ok {
isInternal = true
@ -145,7 +138,6 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
}
userID = reset.ID
username = reset.Username
status, err := app.jf.ResetPasswordAdmin(userID)
if !(status == 200 || status == 204) || err != nil {
app.err.Printf("Password Reset failed (%d): %v", status, err)
@ -187,7 +179,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: user.ID,
Time: time.Now(),
}, gc, true)
})
prevPassword := req.PIN
if isInternal {

45
auth.go
View File

@ -18,25 +18,6 @@ const (
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 {
return app.authenticate
}
@ -152,7 +133,7 @@ type getTokenDTO struct {
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)
auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2)
@ -160,7 +141,7 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
password = creds[1]
ok = false
if username == "" || password == "" {
app.logIpDebug(gc, userpage, "Auth denied: blank username/password")
app.debug.Println("Auth denied: blank username/password")
respond(401, "Unauthorized", gc)
return
}
@ -168,17 +149,17 @@ func (app *appContext) decodeValidateLoginHeader(gc *gin.Context, userpage bool)
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
user, status, err := app.authJf.Authenticate(username, password)
if status != 200 || err != nil {
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)
return
}
if status == 403 {
app.logIpInfo(gc, userpage, "Auth denied: Jellyfin account disabled")
app.info.Println("Auth denied: Jellyfin account disabled")
respond(403, "yourAccountWasDisabled", gc)
return
}
@ -199,8 +180,8 @@ func (app *appContext) validateJellyfinCredentials(username, password string, gc
// @tags Auth
// @Security getTokenAuth
func (app *appContext) getTokenLogin(gc *gin.Context) {
app.logIpInfo(gc, false, "Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc, false)
app.info.Println("Token requested (login attempt)")
username, password, ok := app.decodeValidateLoginHeader(gc)
if !ok {
return
}
@ -215,12 +196,12 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
}
}
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)
return
}
if !match {
user, ok := app.validateJellyfinCredentials(username, password, gc, false)
user, ok := app.validateJellyfinCredentials(username, password, gc)
if !ok {
return
}
@ -252,8 +233,7 @@ func (app *appContext) getTokenLogin(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.SetCookie("refresh", refresh, (3600 * 24), "/", gc.Request.URL.Hostname(), true, true)
gc.JSON(200, getTokenDTO{token})
}
@ -304,7 +284,7 @@ func (app *appContext) decodeValidateRefreshCookie(gc *gin.Context, cookieName s
// @Router /token/refresh [get]
// @tags Auth
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")
if !ok {
return
@ -317,7 +297,6 @@ func (app *appContext) getTokenRefresh(gc *gin.Context) {
respond(500, "Couldn't generate token", gc)
return
}
host := gc.Request.URL.Hostname()
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", host, true, true)
gc.SetCookie("refresh", refresh, REFRESH_TOKEN_VALIDITY_SEC, "/", gc.Request.URL.Hostname(), true, true)
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_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
url1 := app.config.Section("invite_emails").Key("url_base").String()
url2 := app.config.Section("password_resets").Key("url_base").String()

View File

@ -297,29 +297,6 @@
"advanced": true
},
"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": {
"name": "TLS/HTTP2",
"required": false,

View File

@ -106,6 +106,48 @@ div.card:contains(section.banner.footer) {
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 {
display: flex;
flex-wrap: wrap;
@ -130,7 +172,23 @@ span.sm:not(.heading) {
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) {
.row {
flex-direction: column;
@ -161,6 +219,69 @@ sup.\~critical, .text-critical {
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 {
box-sizing: border-box; /* fixes weird length issue with inputs */
}
@ -179,6 +300,10 @@ sup.\~critical, .text-critical {
width: 100%;
}
.flex-auto {
flex: auto;
}
.center {
justify-content: center;
}
@ -187,6 +312,14 @@ sup.\~critical, .text-critical {
align-items: center;
}
.no-lp {
padding-left: 0px;
}
.block {
display: block;
}
.focused {
display: block;
}
@ -283,16 +416,7 @@ table {
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 {
margin-top: 0px;
@ -451,6 +575,7 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
cursor: pointer;
}
.g-recaptcha {
overflow: hidden;
width: 296px;
@ -462,8 +587,3 @@ input[type="checkbox" i], [class^="ri-"], [class*=" ri-"], .ri-refresh-line:befo
.g-recaptcha iframe {
margin: -2px 0px 0px -4px;
}
.dropdown-manual-toggle {
margin-bottom: -0.5rem;
padding-bottom: 0.5rem;
}

View File

@ -5,7 +5,6 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 10rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
@ -14,23 +13,12 @@
border-radius: 6px;
overflow-wrap: break-word;
text-align: center;
transition: opacity 100ms;
position: absolute;
z-index: 1;
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 {
left: 120%;
}
@ -43,10 +31,6 @@
font-size: 0.8rem;
}
.tooltip:hover .content,
.tooltip:focus .content,
.tooltip:focus-within .content
{
.tooltip:hover .content {
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() {
app.debug.Println("Housekeeping: Cleaning up Activity log...")
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)
clearTelegram := app.config.Section("telegram").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 {
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 {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearMatrix() })
}
if clearPWR {
daemon.jobs = append(daemon.jobs, func(app *appContext) { app.clearPWRCaptchas() })
}
return &daemon
}

View File

@ -26,7 +26,7 @@
<body class="max-w-full overflow-x-hidden section">
{{ template "login-modal.html" . }}
<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>
<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 }}">
@ -43,31 +43,31 @@
</form>
</div>
<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">
<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.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.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">
<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 ~urge 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>
<div class="dropdown" tabindex="0">
<a href="https://github.com/sponsors/hrfee" target="_blank" class="button ~info dropdown-button lang-link">
<div class="row col flex">
<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 mt-4 mb-4 mr-2 lang-link" href="https://wiki.jfa-go.com">wiki/docs</a>
<a class="button ~positive mt-4 mb-4 mr-2 lang-link" href="https://weblate.jfa-go.com">translation</a>
<div class="dropdown mr-2" tabindex="0">
<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>
donate
<span class="ml-2 chev"></span>
</a>
<div class="dropdown-display">
<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://ko-fi.com/hrfee" target="_blank" class="button ~neutral mb-2 w-full lang-link">Ko-fi</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-100 lang-link">Ko-fi</a>
</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>
<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>
@ -80,15 +80,15 @@
</div>
</div>
<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>
<p class="content my-4">{{ .strings.modifySettingsDescription }}</p>
<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>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</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">
<span class="button ~neutral @low supra full-width center">{{ .strings.user }}</span>
</label>
@ -111,15 +111,15 @@
</div>
{{ if .referralsEnabled }}
<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>
<p class="content my-4">{{ .strings.enableReferralsDescription }}</p>
<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>
<span class="button ~neutral @high supra full-width center">{{ .strings.profile }}</span>
</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">
<span class="button ~neutral @low supra full-width center">{{ .strings.invite }}</span>
</label>
@ -142,7 +142,7 @@
</form>
</div>
<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>
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
@ -162,7 +162,7 @@
</div>
{{ end }}
<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>
<div class="content mt-8">
<label class="switch mb-4">
@ -178,7 +178,7 @@
</form>
</div>
<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>
<div class="content mt-8">
<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">
<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>
<div class="flex flex-col md:flex-row">
<div class="row">
<div class="col card ~neutral @low">
<div id="announce-details">
<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">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="flex flex-row justify-between">
<div class="row flex-expand">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge @low center supra submit">{{ .strings.send }}</span>
@ -275,10 +275,10 @@
</form>
</div>
<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>
<p class="content my-4">{{ .strings.customizeMessagesDescription }}</p>
<div class="">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
@ -319,7 +319,7 @@
</form>
</div>
<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>
<p class="content my-4">{{ .strings.settingsRestartRequiredDescription }}</p>
<div class="float-right">
@ -329,7 +329,7 @@
</div>
</div>
<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>
<div class="content my-4">
{{ .strings.backupsDescription }}
@ -345,7 +345,7 @@
<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>
</div>
<div class="overflow-x-auto text-xs md:text-sm">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
@ -360,30 +360,30 @@
</div>
</div>
<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>
<p class="content my-4" id="settings-backed-up-location"></p>
<p class="content my-4">{{ .strings.backupCanDownload }}</p>
<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 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>
<p class="content">{{ .strings.settingsRefreshPage }}</p>
</div>
</div>
<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>
<p class="content my-2" id="send-pwr-note"></p>
<span class="button ~urge @low mt-2" id="send-pwr-link">{{ .strings.copy }}</span>
</div>
</div>
<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>
<p class="content my-4">{{ .strings.ombiUserDefaultsDescription }}</p>
<div class="select ~neutral @low mb-4">
@ -396,7 +396,7 @@
</form>
</div>
<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>
<p class="content my-4">{{ .strings.userProfilesDescription }}</p>
<div class="table-responsive">
@ -422,7 +422,7 @@
</div>
</div>
<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>
<p class="content my-4">{{ .strings.addProfileDescription }}</p>
<label>
@ -445,7 +445,7 @@
</form>
</div>
<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>
<p class="content">
<h2 class="mt-2">
@ -461,7 +461,7 @@
</div>
{{ if .telegramEnabled }}
<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>
<p class="content mb-4">{{ .strings.sendPIN }}</p>
<h1 class="ac" id="telegram-pin"></h1>
@ -479,7 +479,7 @@
{{ end }}
{{ if .discordEnabled }}
<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>
<p class="content mb-4" id="discord-description"></p>
<div class="row">
@ -490,7 +490,7 @@
</div>
{{ end }}
<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>
<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">
@ -503,7 +503,7 @@
</form>
</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="button ~urge dropdown-button">
<i class="ri-global-line"></i>
@ -532,8 +532,8 @@
{{ end }}
<div class="page-container">
<div class="mb-4">
<header>
<div class="flex flex-row overflow-x-scroll items-center">
<header class="flex flex-wrap items-center justify-between">
<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-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>
@ -547,55 +547,55 @@
</div>
</div>
<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>
<div id="invites" class="mt-2"></div>
<div id="invites"></div>
</div>
<div class="card @low dark:~d_neutral">
<span class="heading">{{ .strings.create }}</span>
<div class="flex flex-col md:flex-row gap-3 mt-2" id="create-inv">
<div class="card ~neutral @low flex flex-col gap-2 w-1/2">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<div class="flex flex-col md:flex-row gap-3" id="create-inv">
<div class="card ~neutral @low col">
<div class="row mb-2">
<label class="col mr-2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="w-1/2">
<label class="col ml-2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
<div id="inv-duration" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<div id="inv-duration">
<div class="row">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
<div class="grow flex flex-col gap-4">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<div class="row">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
<div class="grow flex flex-col gap-4">
<div class="col">
<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">
<option>0</option>
</select>
@ -603,46 +603,44 @@
</div>
</div>
</div>
<div id="user-expiry" class="unfocused flex flex-col gap-2">
<div class="flex flex-row gap-2">
<p class="support">{{ .strings.userExpiryDescription }}</p>
<div>
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
</div>
<div id="user-expiry" class="unfocused">
<p class="support mb-2">{{ .strings.userExpiryDescription }}</p>
<div class="mb-2">
<label for="create-user-expiry-enabled" class="button ~neutral @low">
<input type="checkbox" id="create-user-expiry-enabled" aria-label="User duration enabled">
<span class="ml-2">{{ .strings.enabled }} </span>
</label>
</div>
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<div class="row">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
<div class="grow flex flex-col gap-4">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-4">
<div class="row">
<div class="col">
<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">
<option>0</option>
</select>
</div>
</div>
<div class="grow flex flex-col gap-4">
<div class="col">
<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">
<option>0</option>
</select>
@ -650,91 +648,77 @@
</div>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="col">
<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 class="flex flex-col gap-4">
<div>
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
</div>
<input type="text" id="create-user-label" class="input ~neutral @low">
<div class="col">
<label class="label supra" for="create-user-label"> {{ .strings.userLabel }}</label>
<p class="support">{{ .strings.userLabelDescription }}</p>
<input type="text" id="create-user-label" class="input ~neutral @low mb-2 mt-4">
</div>
</div>
<div class="card ~neutral @low flex flex-col justify-between gap-2 w-1/2">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex flex-row gap-2">
<input type="number" min="0" id="create-uses" class="input ~neutral @low" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
</div>
<p class="support unfocused" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<div class="flex flex-col gap-4">
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low">
<select id="create-profile">
</select>
</div>
</div>
<div id="create-send-to-container" class="flex flex-col gap-4">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex flex-row gap-2">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<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 class="card ~neutral @low col">
<label class="label supra" for="create-uses">{{ .strings.inviteNumberOfUses }}</label>
<div class="flex-expand mb-2 mt-4">
<input type="number" min="0" id="create-uses" class="input ~neutral @low mr-2" value=1>
<label for="create-inf-uses" class="button ~neutral @low" title="Set uses to infinite">
<span>&infin;</span>
<input type="checkbox" class="unfocused" id="create-inf-uses" aria-label="Set uses to infinite">
</label>
</div>
<p class="support unfocused my-2" id="create-inf-uses-warning"><span class="badge ~critical">{{ .strings.warning }}</span> {{ .strings.inviteInfiniteUsesWarning }}</p>
<label class="label supra">{{ .strings.profile }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<select id="create-profile">
</select>
</div>
<div id="create-send-to-container">
<label class="label supra">{{ .strings.inviteSendToEmail }}</label>
<div class="flex-expand mb-2 mt-4">
{{ if .discordEnabled }}
<input type="text" id="create-send-to" class="input ~neutral @low mr-2" placeholder="example@example.com | user#1234">
<span id="create-send-to-search" class="button ~neutral @low mr-2">
<i class="icon ri-search-2-line" title="{{ .strings.search }}"></i>
</span>
{{ else }}
<input type="email" id="create-send-to" class="input ~neutral @low mr-2" 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>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
<span class="button ~urge @low supra full-width center lg" id="create-submit">{{ .strings.create }}</span>
</div>
</div>
</div>
</div>
<div id="tab-accounts" class="unfocused">
<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 flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</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>
<div class="flex flex-row align-middle w-full">
<input type="search" class="field ~neutral @low input search 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="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 class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.accounts }}</span>
<div id="accounts-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</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 class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<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>
<span id="accounts-filter-area"></span>
</div>
<div class="supra pt-1 pb-2 sm">{{ .strings.actions }}</div>
<div class="flex flex-row flex-wrap gap-3 mb-4">
<span class="button ~neutral @low center " id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="dropdown pb-0i " tabindex="0">
<span class="w-full button ~info @low center items-baseline" id="accounts-announce">{{ .strings.announce }}</span>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
@ -742,12 +726,12 @@
</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 }}
<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 }}
<div id="accounts-expiry-dropdown" class="dropdown pb-0i " 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>
<div id="accounts-expiry-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<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="card ~neutral @low">
<span class="button ~warning full-width @low center" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
@ -755,16 +739,16 @@
</div>
</div>
</div>
<div id="accounts-disable-enable-dropdown" class="dropdown manual pb-0i " tabindex="0">
<span class="w-full button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="button ~info @low center unfocused " 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 ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div>
<div class="card @low accounts-header table-responsive mt-2">
<table class="table text-base leading-4">
@ -807,33 +791,27 @@
</div>
<div id="tab-activity" class="unfocused">
<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 flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div class="flex flex-row align-middle">
<span class="dropdown-manual-toggle"><button class="h-full button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</button></span>
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
<div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</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>
<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 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 flex flex-row gap-2">
<span id="activity-total-records"></span>
<span id="activity-loaded-records"></span>
<span id="activity-shown-records"></span>
<div class="supra sm">
<span id="activity-total-records" class="mx-2"></span>
<span id="activity-loaded-records" class="mx-2"></span>
<span id="activity-shown-records" class="mx-2"></span>
</div>
</div>
<div class="row -mx-2 mb-2">
@ -864,24 +842,24 @@
</div>
<div id="tab-settings" class="unfocused">
<div class="card @low dark:~d_neutral settings overflow">
<div class="flex flex-col md:flex-row align-middle gap-2">
<div class="flex flex-row align-middle justify-between md:justify-normal">
<div class="flex-expand">
<div class="flex-row">
<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">
<span class="ml-2">{{ .strings.advancedSettings }} </span>
</label>
</div>
<div class="flex flex-row justify-start md:justify-end gap-2 w-full">
<span class="button ~neutral @low" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused" id="settings-save">{{ .strings.settingsSave }}</span>
<div>
<span class="button ~neutral @low my-1" id="settings-logs">{{ .strings.logs }}</span>
<span class="button ~info @low my-1" id="settings-backups">{{ .strings.backups }}</span>
<span class="button ~neutral @low my-1" id="settings-restart">{{ .strings.settingsRestart }}</span>
<span class="button ~urge @low unfocused my-1" id="settings-save">{{ .strings.settingsSave }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<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 }}">
<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>

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>
</section>
<section class="section ~neutral @low">
<div class="flex flex-row justify-between">
<div class="flex-expand">
<span class="subheading">Full Log</span>
<span class="button ~urge ml-4" id="copy-log">Copy</span>
</div>

View File

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

View File

@ -1,21 +1,21 @@
<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 .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 }}
</div>
{{ end }}
{{ end }}
{{ if index . "userPageEnabled" }}
{{ 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>
<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>
{{ 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>
<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">

View File

@ -35,7 +35,7 @@
<aside class="aside ~warning">
{{ .strings.changeYourPassword }}
</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 }}
</div>
<i class="content">{{ .contactMessage }}</i>

View File

@ -30,7 +30,7 @@
<div class="row col flex center">
<p class="content my-2">{{ .lang.StartPage.pressStart }}</p>
</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="button ~urge @low next">{{ .lang.StartPage.start }}</span>
</section>
@ -59,7 +59,7 @@
</select>
</div>
</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 ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -150,7 +150,7 @@
</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 ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -189,7 +189,7 @@
<span class="support mb-2 mt-1">{{ .lang.Login.emailNotice }}</span>
</label>
</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 ~urge @low next">{{ .lang.Strings.next }}</span>
</section>
@ -235,7 +235,7 @@
</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>
<div>
<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">
<p class="support mb-2 mt-1">{{ .lang.Ombi.apiKeyNotice }}</p>
</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>
<div>
<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>
</label>
<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>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -370,7 +370,7 @@
</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>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -392,7 +392,7 @@
<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 }}">
</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>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -413,7 +413,7 @@
<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 }}">
</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>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -450,7 +450,7 @@
<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 }}">
</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>
<div>
<span class="button ~urge @low next">{{ .lang.Strings.next }}</span>
@ -483,7 +483,7 @@
<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">
</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>
<div>
<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">
<p class="support mb-2 mt-1">{{ .lang.HelpMessages.emailMessageNotice }}</p>
</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>
<div>
<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">
<span class="heading mb-2"></span>
</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 .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 }}
</div>
{{ end }}
@ -162,7 +162,7 @@
<div class="card @low dark:~d_neutral unfocused" id="card-referrals">
<span class="heading mb-2">{{ .strings.referrals }}</span>
<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="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>

View File

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

View File

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

View File

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

View File

@ -130,53 +130,7 @@
"searchOptions": "Zoekopties",
"matchText": "Tekstovereenkomst",
"jellyfinID": "Jellyfin ID",
"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"
"userPagePage": "Gebruikerspagina: Pagina"
},
"notifications": {
"changedEmailAddress": "E-mailadres van {n} gewijzigd.",
@ -216,11 +170,7 @@
"setOmbiProfile": "Opgeslagen ombi-profiel.",
"errorSetOmbiProfile": "Opslaan van ombi-profiel mislukt.",
"errorNoReferralTemplate": "Profiel bevat geen verwijzingssjabloon, voeg er een toe bij instellingen.",
"referralsEnabled": "Verwijzingen actief.",
"activityDeleted": "Activiteit verwijderd.",
"errorInviteNoLongerExists": "Uitnodiging bestaat niet meer.",
"errorInviteNotFound": "Uitnodiging niet gevonden.",
"errorLoadActivities": "Laden van activiteiten mislukt."
"referralsEnabled": "Verwijzingen actief."
},
"quantityStrings": {
"modifySettingsFor": {

View File

@ -69,7 +69,7 @@
"ombiUserDefaults": "Ombi 用户默认值",
"ombiUserDefaultsDescription": "创建并配置 Ombi 用户,然后在下面选择它。它的设置/权限将被存储并应用于由 jfa-go 创建的新 Ombi 用户。",
"userProfiles": "用户档案",
"userProfilesDescription": "个人资料在用户创建帐户时应用于他们。个人资料包括库访问权限和主屏幕布局。",
"userProfilesDescription": "配置文件在用户创建帐户时应用于用户。配置文件包括库访问权限和主屏幕布局。",
"userProfilesIsDefault": "默认",
"userProfilesLibraries": "库",
"addProfile": "添加档案",
@ -117,66 +117,7 @@
"before": "之前",
"unlink": "取消关联帐户",
"sortingBy": "排序方式",
"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}"
"userPageLogin": "用户页面:登录"
},
"notifications": {
"changedEmailAddress": "更改了 {n} 的电子邮件地址。",
@ -214,13 +155,7 @@
"updateAvailable": "有新更新可用,请检查设置。",
"noUpdatesAvailable": "没有可用的更新。",
"setOmbiProfile": "保存ombi配置文件。",
"errorSetOmbiProfile": "无法保存ombi配置文件。",
"activityDeleted": "活动已删除。",
"errorNoReferralTemplate": "个人资料不包含推荐模板,请在设置中添加一个。",
"referralsEnabled": "已启用推荐。",
"errorInviteNoLongerExists": "邀请已不存在。",
"errorInviteNotFound": "未找到邀请。",
"errorLoadActivities": "无法加载活动。"
"errorSetOmbiProfile": "无法保存ombi配置文件。"
},
"quantityStrings": {
"modifySettingsFor": {
@ -278,10 +213,6 @@
"setExpiry": {
"plural": "为{n}用户设置到期时间",
"singular": "为{n}用户设置到期时间"
},
"enableReferralsFor": {
"singular": "为{n}用户启用推荐功能",
"plural": "为{n}个用户启用推荐功能"
}
}
}

View File

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

View File

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

View File

@ -22,8 +22,7 @@
"errorNotAdmin": "Gebruiker heeft geen beheersrechten.",
"errorConnectionRefused": "Verbinding geweigerd.",
"errorUnknown": "Onbekende fout, bekijk de logs.",
"error": "Fout",
"errorProxy": "Proxy-instellingen onjuist."
"error": "Fout"
},
"startPage": {
"welcome": "Welkom!",
@ -157,11 +156,5 @@
"title": "Gebruikerspagina",
"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."
},
"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": "上一步",
"optional": "可选的",
"serverType": "服务类型",
"disabled": "禁用",
"disabled": "禁用",
"enabled": "已启用",
"port": "端口",
"message": "信息",
@ -17,13 +17,12 @@
"URL": "链接",
"apiKey": "API 密钥",
"errorInvalidUserPass": "无效的用户名/密码。",
"errorNotAdmin": "用户没有权限管理服务器。",
"errorNotAdmin": "此用户不允许管理服务。",
"errorUserDisabled": "此永固可能已被禁用。",
"error404": "404请检查内部URL。",
"errorConnectionRefused": "连接被拒绝。",
"error": "错误",
"errorUnknown": "未知错误,请检查应用程序日志。",
"errorProxy": "代理配置无效。"
"errorUnknown": "未知错误,请检查应用程序日志。"
},
"startPage": {
"welcome": "欢迎!",
@ -71,8 +70,7 @@
"adminOnly": "仅允许管理员账户(推荐)",
"emailNotice": "您的电子邮件地址可以用来接收通知。",
"allowAllDescription": "不建议,您应该允许单个用户在设置后登录。",
"allowAll": "允许所有Jellyfin用户登录",
"authorizeManualUserPageNotice": "使用此选项将禁用“用户页面”功能。"
"allowAll": "允许所有Jellyfin用户登录"
},
"jellyfinEmby": {
"title": "Jellyfin/Emby",
@ -128,8 +126,7 @@
"resetLinksNotice": "如果启用了 Ombi 集成,请使用它与 Ombi 同步 Jellyfin 密码重置。",
"resetLinksLanguage": "默认重置链接语言",
"setPassword": "通过链接设置密码",
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。",
"resetLinksRequiredForUserPage": "对于用户页面上的自助密码重置,这是必需的。"
"setPasswordNotice": "启用此功能意味着用户无需在重置后通过 PIN 更改其密码。并将强制执行密码验证。"
},
"passwordValidation": {
"title": "密码验证",
@ -151,17 +148,5 @@
"successMessageNotice": "在用户创建账户时显示。",
"emailMessage": "电子邮件",
"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码>`以验证您的账户。",
"languageMessageDiscord": "提示:使用 `/lang <语言>` 设置语言。",
"languageSet": "语言改成 {language}。",
"discordDMs": "请检查您的DM找回答。",
"sentInvite": "已发送邀请。",
"sentInviteFailure": "发送邀请失败,请检查日志。"
"discordDMs": "请检查您的DM找回答。"
}
}
}

14
main.go
View File

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

View File

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

View File

@ -7,11 +7,11 @@ all:
cp -r ts tempts
../scripts/dark-variant.sh tempts
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
cd out && npx uncss index.html --stylesheets remixicon.css > _remixicon.css; cd ..
mv out/_remixicon.css out/remixicon.css
cp -r ../static/* out/
cp ../static/* out/
node inject.js
debug:
@ -22,10 +22,10 @@ debug:
-rm -r tempts
cp -r ts 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 tailwindcss -c tailwind.config.js -i out/bundle.css -o out/bundle.css
cp -r ../static/* out/
cp ../static/* out/
monitor:
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">
<span class="heading"> Debian/Ubuntu (apt)</span>
<div class="mt-1">
<pre style="margin: 0; line-height: 125%">sudo apt-get update && sudo apt-get install curl apt-transport-https gnupg
curl https://apt.hrfee.dev/hrfee.pubkey.gpg | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/apt.hrfee.dev.gpg
<pre style="margin: 0; line-height: 125%">curl https://apt.hrfee.dev/hrfee.pubkey.gpg | sudo apt-key add -
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>
</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>
</section>
</div>

View File

@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/hrfee/jfa-go/logger"
"github.com/hrfee/mediabrowser"
"github.com/timshannon/badgerhold/v4"
@ -56,7 +55,6 @@ type Activity struct {
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.
Time time.Time
IP string
}
type UserExpiry struct {
@ -565,12 +563,8 @@ func (st *Storage) GetActivityKey(k string) (Activity, bool) {
}
// 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, gc *gin.Context, user bool) {
func (st *Storage) SetActivityKey(k string, v Activity) {
v.ID = k
if gc != nil && ((LOGIPU && user) || (LOGIP && !user)) {
v.IP = gc.ClientIP()
}
err := st.db.Upsert(k, v)
if err != nil {
// 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 { activityList } from "./modules/activity.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 { Login } from "./modules/login.js";
@ -216,8 +216,6 @@ login.onLogin = () => {
}
}
bindManualDropdowns();
login.bindLogout(document.getElementById("logout-button"));
login.login("", "");

View File

@ -4,7 +4,6 @@ import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from
import { loadLangSelector } from "./modules/lang.js";
import { Validator, ValidatorConf, ValidatorRespDTO } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@ -173,7 +172,35 @@ if (!window.usernameEnabled) { usernameField.parentElement.remove(); usernameFie
const passwordField = document.getElementById("create-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 {
if (window.emailRequired) {
@ -201,9 +228,20 @@ function _baseValidator(oncomplete: (valid: boolean) => void, captchaValid: bool
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 = {
passwordField: passwordField,
@ -235,15 +273,29 @@ interface sendDTO {
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) {
captcha.generate();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = captcha.generate;
captcha.input.onkeyup = validator.validate;
genCaptcha();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
captchaInput.onkeyup = validator.validate;
}
const create = (event: SubmitEvent) => {
event.preventDefault();
if (window.captcha && !window.reCAPTCHA && !captcha.verified) {
if (window.captcha && !window.reCAPTCHA && !captchaVerified) {
}
addLoader(submitSpan);
@ -278,8 +330,8 @@ const create = (event: SubmitEvent) => {
if (window.reCAPTCHA) {
send.captcha_text = grecaptcha.getResponse();
} else {
send.captcha_id = captcha.captchaID;
send.captcha_text = captcha.input.value;
send.captcha_id = captchaID;
send.captcha_text = captchaInput.value;
}
}
_post("/newUser", send, (req: XMLHttpRequest) => {

View File

@ -188,7 +188,7 @@ class user implements User, SearchableItem {
if (!telegram && !discord && !matrix && !email) return;
let innerHTML = `
<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="card ~neutral @low">
<div class="supra sm mb-2">${window.lang.strings("contactThrough")}</div>
@ -218,13 +218,13 @@ class user implements User, SearchableItem {
</div>
<div class="supra sm mb-2 accounts-unlink-header">${window.lang.strings("unlink")}:</div>
<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 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 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>
@ -1271,7 +1271,7 @@ export class accountsList {
dList.textContent = '';
for (let name of list) {
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 = `
<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 = () => {
let date: Date;
let invalid = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
@ -1646,7 +1645,7 @@ export class accountsList {
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
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;
else {
date = new Date(this._users[id].expiry*1000);
@ -1666,12 +1665,7 @@ export class accountsList {
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.innerHTML = `
<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.textContent = window.lang.strings("accountWillExpire").replace("{date}", toDateString(date));
this._extendExpiryDate.classList.remove("unfocused");
}
}
@ -1746,9 +1740,6 @@ export class accountsList {
}
}
this._extendExpiryTextInput.value = "";
this._usingExtendExpiryTextInput = false;
this._extendExpiryDate.classList.add("unfocused");
this._displayExpiryDate();
window.modals.extendExpiry.show();
}

View File

@ -14,7 +14,6 @@ export interface activity {
time: number;
username: string;
source_username: string;
ip: string;
}
var activityTypeMoods = {
@ -44,7 +43,6 @@ export class Activity implements activity, SearchableItem {
private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _ip: HTMLElement;
private _act: activity;
private _urlBase: string = ((): string => {
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; }
set invite_code(v: string) {
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>
</div>
</div>
<div class="flex flex-row justify-between items-end">
<div class="flex flex-col md:flex-row gap-2">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div class="flex flex-col md:flex-row justify-between">
<div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div>
<div>
<span class="content activity-referrer"></span>
<span class="content activity-ip"></span>
</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>
@ -290,7 +277,6 @@ export class Activity implements activity, SearchableItem {
this._time = this._card.querySelector(".activity-time");
this._sourceType = this._card.querySelector(".activity-source-type");
this._source = this._card.querySelector(".activity-source");
this._ip = this._card.querySelector(".activity-ip");
this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
@ -338,7 +324,6 @@ export class Activity implements activity, SearchableItem {
this.source = act.source;
this.value = act.value;
this.type = act.type;
this.ip = act.ip;
}
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();
}
}
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 }
set expiresIn(expiry: string) {
this._expiresIn = expiry;
this._codeArea.querySelector("span.inv-duration").textContent = expiry;
this._infoArea.querySelector("span.inv-duration").textContent = expiry;
}
private _userExpiry: string;
@ -110,15 +110,15 @@ class DOMInvite implements Invite {
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (address == "") {
container.classList.remove("mr-4");
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.remove("~critical");
chip.classList.remove("button");
chip.parentElement.classList.remove("h-full");
chip.classList.remove("chip");
} else {
chip.classList.add("button");
chip.parentElement.classList.add("h-full");
container.classList.add("mr-4");
chip.classList.add("chip");
if (address.includes("Failed")) {
icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line");
@ -146,11 +146,10 @@ class DOMInvite implements Invite {
}
this._right.classList.remove("empty");
let innerHTML = `
<table class="table inv-table table-p-0">
<table class="table inv-table">
<thead>
<tr>
<th>${window.lang.strings("name")}</th>
<th class="w-2"></th>
<th>${window.lang.strings("date")}</th>
</tr>
</thead>
@ -160,7 +159,6 @@ class DOMInvite implements Invite {
innerHTML += `
<tr>
<td>${username}</td>
<td class="w-2"></td>
<td>${toDateString(new Date(uB[username] * 1000))}</td>
</tr>
`;
@ -268,21 +266,18 @@ class DOMInvite implements Invite {
constructor(invite: Invite) {
// first create the invite structure, then use our setter methods to fill in the data.
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._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._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 = `
<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 truncate" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
</div>
<span class="inv-duration"></span>
<a class="invite-link text-black dark:text-white font-mono bg-inherit mr-4" href=""></a>
<span class="button ~info @low" title="${window.lang.strings("copy")}"><i class="ri-file-copy-line"></i></span>
`;
const copyButton = this._codeArea.querySelector("span.button") as HTMLSpanElement;
copyButton.onclick = () => {
@ -302,15 +297,16 @@ class DOMInvite implements Invite {
this._infoArea = document.createElement('div') as HTMLDivElement;
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 = `
<div class="tooltip below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
<div class="tooltip left">
<span class="inv-email-chip"><i></i></span>
<span class="content sm"></span>
</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>
<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">
</label>
`;
@ -319,30 +315,25 @@ class DOMInvite implements Invite {
const toggle = (this._infoArea.querySelector("input.inv-toggle-details") as HTMLInputElement);
toggle.onchange = () => { this.expanded = !this.expanded; };
const toggleDetails = (event: Event) => {
if (event.target == this._header || event.target == this._codeArea || event.target == this._infoArea) {
this._header.onclick = (event: Event) => {
if (event.target == this._header) {
this.expanded = !this.expanded;
}
};
this._header.onclick = toggleDetails;
this._details = document.createElement('div') as HTMLDivElement;
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;
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.classList.add("flex", "flex-row", "flex-wrap", "gap-4", "min-w-full", "sm:min-w-fit", "whitespace-nowrap");
detailsInner.appendChild(this._left);
const leftLeft = document.createElement("div") as HTMLDivElement;
this._left.appendChild(leftLeft);
leftLeft.classList.add("inv-profilearea", "min-w-full", "sm:min-w-fit");
this._left.classList.add("inv-profilearea");
let innerHTML = `
<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>
<option value="noProfile" selected>${window.lang.strings("inviteNoProfile")}</option>
</select>
@ -361,7 +352,7 @@ class DOMInvite implements Invite {
</label>
`;
}
leftLeft.innerHTML = innerHTML;
this._left.innerHTML = innerHTML;
(this._left.querySelector("select") as HTMLSelectElement).onchange = this.updateProfile;
if (window.notificationsEnabled) {
@ -373,21 +364,20 @@ class DOMInvite implements Invite {
}
this._middle = document.createElement('div') as HTMLDivElement;
this._left.appendChild(this._middle);
this._middle.classList.add("flex", "flex-col", "justify-between");
detailsInner.appendChild(this._middle);
this._middle.classList.add("block");
this._middle.innerHTML = `
<p class="supra 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"><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="supra mb-4 top">${window.lang.strings("inviteDateCreated")} <strong class="inv-created"></strong></p>
<p class="supra mb-4">${window.lang.strings("inviteRemainingUses")} <strong class="inv-remaining"></strong></p>
<p class="supra mb-4"><span class="user-expiry"></span> <strong class="user-expiry-time"></strong></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;
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.innerHTML = `<span class="supra table-header">${window.lang.strings("inviteUsersCreated")}</span>`;
this._right.classList.add("card", "~neutral", "@low", "inv-created-users");
this._right.innerHTML = `<strong class="supra table-header">${window.lang.strings("inviteUsersCreated")}</strong>`;
this._userTable = document.createElement('div') as HTMLDivElement;
this._userTable.classList.add("text-sm", "mt-1", );
this._right.appendChild(this._userTable);
@ -480,8 +470,8 @@ export class inviteList implements inviteList {
this._list.classList.add("empty");
this._list.innerHTML = `
<div class="inv inv-empty">
<div class="card dark:~d_neutral @low inv-header mt-2">
<div class="justify-start">
<div class="card dark:~d_neutral @low inv-header flex-expand mt-2">
<div class="inv-codearea">
<span class="text-black dark:text-white font-mono bg-inherit">${window.lang.strings("inviteNoInvites")}</span>
</div>
</div>

View File

@ -83,7 +83,7 @@ export const loadLangSelector = (page: string) => {
let innerHTML = '';
for (let code in req.response) {
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");
}
list.innerHTML = innerHTML;

View File

@ -86,7 +86,7 @@ class profile implements Profile {
<td><span class="button @low profile-referrals"></span></td>
`;
innerHTML += `
<td class="profile-from truncate"></td>
<td class="profile-from ellipsis"></td>
<td class="profile-libraries"></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;
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 = `
<div class="flex flex-col mr-2">
<span>${query.name}</span>

View File

@ -22,18 +22,13 @@ export class Tabs implements Tabs {
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
this._current = tabID;
let baseOffset = -1;
for (let t of this.tabs) {
if (baseOffset == -1) baseOffset = t.buttonEl.offsetLeft;
if (t.tabID == tabID) {
t.buttonEl.classList.add("active", "~urge");
if (t.preFunc && !noRun) { t.preFunc(); }
t.tabEl.classList.remove("unfocused");
if (t.postFunc && !noRun) { t.postFunc(); }
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 {
t.buttonEl.classList.remove("active");
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 { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js";
import { Captcha, GreCAPTCHA } from "./modules/captcha.js";
interface formWindow extends Window {
invalidPassword: string;
@ -29,10 +28,6 @@ interface formWindow extends Window {
userExpiryHours: number;
userExpiryMinutes: number;
userExpiryMessage: string;
captcha: boolean;
reCAPTCHA: boolean;
reCAPTCHASiteKey: string;
pwrPIN: string;
}
loadLangSelector("pwr");
@ -47,26 +42,11 @@ const rePasswordField = document.getElementById("create-reenter-password") as HT
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 = {
passwordField: passwordField,
rePasswordField: rePasswordField,
submitInput: submitInput,
submitButton: submitSpan,
validatorFunc: baseValidator
submitButton: submitSpan
};
var validator = new Validator(validatorConf);
@ -75,13 +55,6 @@ var requirements = validator.requirements;
interface sendDTO {
pin: 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) => {
@ -92,31 +65,12 @@ form.onsubmit = (event: Event) => {
pin: params.get("pin"),
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) => {
if (req.readyState == 4) {
removeLoader(submitSpan);
if (req.status == 400) {
if (req.response["error"] as string) {
const old = submitSpan.textContent;
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; }
}
for (let type in req.response) {
if (requirements[type]) { requirements[type].valid = req.response[type] as boolean; }
}
return;
} 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) => {
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 = `
<div class="flex items-baseline flex-nowrap truncate">
<div class="flex items-baseline flex-nowrap ellipsis">
<span class="shield ~urge" alt="${name}">
<span class="icon">
${icon}
@ -637,10 +637,10 @@ document.addEventListener("details-reload", () => {
if (typeof(messageCard) != "undefined" && messageCard != null) {
messageCard.innerHTML = messageCard.innerHTML.replace(new RegExp("{username}", "g"), details.username);
// setBestRowSpan(messageCard, false);
setBestRowSpan(messageCard, false);
// contactCard.querySelector(".content").classList.add("h-100");
} else if (!statusCard.classList.contains("unfocused")) {
// setBestRowSpan(passwordCard, true);
setBestRowSpan(passwordCard, true);
}
if (window.referralsEnabled) {
@ -649,69 +649,15 @@ document.addEventListener("details-reload", () => {
if (req.readyState != 4 || req.status != 200) return;
const referral: MyReferral = req.response as MyReferral;
referralCard.update(referral);
setCardOrder(messageCard);
});
} else {
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");
login.onLogin = () => {
console.log("Logged in.");
@ -753,24 +699,6 @@ const computeRealHeight = (el: HTMLElement): number => {
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.login("", "");

View File

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

View File

@ -120,7 +120,7 @@ func (app *appContext) checkUsers() {
continue
}
app.storage.SetActivityKey(shortuuid.New(), activity, nil, false)
app.storage.SetActivityKey(shortuuid.New(), activity)
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
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["discordEnabled"] = 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)
return
}
@ -365,7 +361,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
SourceType: ActivityUser,
Source: jfUser.ID,
Time: time.Now(),
}, gc, true)
})
}
}
@ -397,28 +393,20 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
// @Router /captcha/img/{code}/{captchaID} [get]
func (app *appContext) GetCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
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
ok := true
if !isPWR {
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(),
})
}
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
} else {
ok = false
}
} else {
capt, ok = app.pwrCaptchas[code]
ok = true
if inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
}
if !ok {
respondBool(400, false, gc)
@ -437,13 +425,7 @@ func (app *appContext) GetCaptcha(gc *gin.Context) {
// @tags Users
func (app *appContext) GenCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
var inv Invite
ok := true
if !isPWR {
inv, ok = app.storage.GetInvitesKey(code)
}
inv, ok := app.storage.GetInvitesKey(code)
if !ok {
gcHTML(gc, 404, "invalidCode.html", gin.H{
"urlBase": app.getURLBase(gc),
@ -458,7 +440,7 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc)
return
}
if !isPWR && inv.Captchas == nil {
if inv.Captchas == nil {
inv.Captchas = map[string]Captcha{}
}
captchaID := genAuthToken()
@ -468,43 +450,26 @@ func (app *appContext) GenCaptcha(gc *gin.Context) {
respondBool(500, false, gc)
return
}
if isPWR {
if app.pwrCaptchas == nil {
app.pwrCaptchas = map[string]Captcha{}
}
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)
inv.Captchas[captchaID] = Captcha{
Answer: capt.Text,
Image: buf.Bytes(),
Generated: time.Now(),
}
app.storage.SetInvitesKey(code, inv)
gc.JSON(200, genCaptchaDTO{captchaID})
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)
if !reCAPTCHA {
// internal CAPTCHA
var c Captcha
ok := true
if !isPWR {
inv, ok := app.storage.GetInvitesKey(code)
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]
inv, ok := app.storage.GetInvitesKey(code)
if !ok || inv.Captchas == nil {
app.debug.Printf("Couldn't find invite \"%s\"", code)
return false
}
c, ok := inv.Captchas[id]
if !ok {
app.debug.Printf("Couldn't find Captcha \"%s\"", id)
return false
@ -564,30 +529,21 @@ func (app *appContext) verifyCaptcha(code, id, text string, isPWR bool) bool {
// @Router /captcha/verify/{code}/{captchaID}/{text} [get]
func (app *appContext) VerifyCaptcha(gc *gin.Context) {
code := gc.Param("invCode")
isPWR := gc.Query("pwr") == "true"
captchaID := gc.Param("captchaID")
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 ok bool
if !isPWR {
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 inv.Captchas != nil {
capt, ok = inv.Captchas[captchaID]
}
if !ok {
respondBool(400, false, gc)
@ -655,7 +611,7 @@ func (app *appContext) InviteProxy(gc *gin.Context) {
app.debug.Printf("Invalid key")
return
}
f, success := app.newUser(req, true, gc)
f, success := app.newUser(req, true)
if !success {
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.