Compare commits

...

39 Commits

Author SHA1 Message Date
dependabot[bot] ec41e63c95
Merge 6ede094034 into 8f2a28e650 2023-12-26 17:02:25 +01:00
undone37 8f2a28e650 translation from Weblate (German)
Currently translated at 71.7% (175 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/de/
2023-12-26 16:33:01 +01:00
nomadics9 8a6102b7b9 translation from Weblate (Arabic)
Currently translated at 29.9% (73 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/ar/
2023-12-26 16:33:01 +01:00
nionionping 0ce5c9923d translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping 4073ebe534 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (12 of 12 strings)

Translation: jfa-go/Telegram/Matrix/Discord bots
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/chat-bots/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping 387fe082ef Translated using Weblate (Chinese (Simplified))
Currently translated at 97.6% (122 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping ddc36ae897 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (51 of 51 strings)

Translation: jfa-go/Common Strings
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/common-strings/zh_Hans/
2023-12-26 16:33:01 +01:00
nionionping c62876ff3a translation from Weblate (Chinese (Simplified))
Currently translated at 100.0% (62 of 62 strings)

Translation: jfa-go/Account Creation Form
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/form/zh_Hans/
2023-12-26 16:33:01 +01:00
Richard de Boer 2fd71acbb2 Translated using Weblate (Dutch)
Currently translated at 99.2% (124 of 125 strings)

Translation: jfa-go/Setup
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/setup/nl/
2023-12-26 16:33:01 +01:00
Richard de Boer 4c1d8ed2a1 translation from Weblate (Dutch)
Currently translated at 100.0% (244 of 244 strings)

Translation: jfa-go/Admin Page
Translate-URL: https://weblate.jfa-go.com/projects/jfa-go/admin/nl/
2023-12-26 16:33:01 +01:00
Harvey Tindall 7223981280
done: use sshkey from secret, not file
update to appleboy/drone-ssh requires special ownership of the ssh key
file, which I can't be bothered with, so we'll source it from a secret
instead. Probably better anyway, that's how the other key was already.
2023-12-26 15:01:33 +00:00
Harvey Tindall 47536f3e63
readme: update latest compatible version 2023-12-26 14:51:53 +00:00
Harvey Tindall ac4fecd819
site: fix for new font 2023-12-26 14:36:13 +00:00
Harvey Tindall b75bd4d6c5
Crash on SSL cert/key error, describe issue in log
if serving ssl/tls fails, the cert/key files are checked to see if they
    are accessible, and any errors logged.
2023-12-26 14:19:22 +00:00
Harvey Tindall 2be7baea4a
trim base css of most redundant classes 2023-12-24 18:55:58 +00:00
Harvey Tindall d56d45a404
userpage: rework dynamic layout, finally 2023-12-24 18:26:35 +00:00
Harvey Tindall b50d66d265
ui: more modal fixes 2023-12-24 15:16:11 +00:00
Harvey Tindall aec0a5349a
ui: fix remaining few modal sizes on mobile 2023-12-24 15:04:58 +00:00
Harvey Tindall 20560332ed
invites: improve inv dropdown wrapping 2023-12-24 14:53:37 +00:00
Harvey Tindall 202ee0977e
invites: improve inv dropdown layout 2023-12-24 14:34:04 +00:00
Harvey Tindall f460bfcfc6
logip: fix user logging 2023-12-24 13:24:18 +00:00
Harvey Tindall 4f5d12f800
invites: ui adjustments, fix duration display > 1y 2023-12-24 02:29:14 +00:00
Harvey Tindall 9092b98b28
accounts: hide previous date example in extend expiry 2023-12-24 01:52:16 +00:00
Harvey Tindall 0f72a85724
accounts: allow extending expiry of more than one user 2023-12-24 01:45:11 +00:00
Harvey Tindall 0840931fed
Merge (optional) IP logging 2023-12-24 01:06:07 +00:00
Harvey Tindall 00379824df
Merge branch 'main' into kimboslice99-main 2023-12-23 21:53:39 +00:00
Harvey Tindall f823705e40
ips: log on activities, show on card 2023-12-23 21:47:41 +00:00
Harvey Tindall 269836fc99
ips: add advanced settings for ip logging 2023-12-23 21:00:32 +00:00
Harvey Tindall 49d8c6f8e4
pwr: add captcha daemon 2023-12-23 20:18:16 +00:00
Harvey Tindall 278588ca39
pwr: functioning captcha/recaptcha 2023-12-23 20:10:48 +00:00
Harvey Tindall ab05c07469
form: modularize captcha somewhat 2023-12-23 18:20:09 +00:00
kimboslice99 04c94ba55a Log IPs 2023-12-23 13:09:49 -05:00
Harvey Tindall 6e205760c3
ui: more invites page improvements/cleanup, fix tooltips on mobile 2023-12-23 17:45:18 +00:00
Harvey Tindall 82032b98a8
invites: improve invite wrapping on mobile 2023-12-23 15:36:28 +00:00
Harvey Tindall e8666d5bf2
ui: general adjustments 2023-12-22 21:40:56 +00:00
Harvey Tindall d1affe271c
ui: wrap settings header 2023-12-22 18:36:21 +00:00
Harvey Tindall ea109c7b63
ui: wrap accounts/activity headers 2023-12-22 18:06:12 +00:00
Harvey Tindall cb5a8c1c23
accounts: position filter dropdown better for mobile 2023-12-22 17:46:57 +00:00
dependabot[bot] 6ede094034
build(deps): bump semver from 5.7.1 to 5.7.2
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-12 06:47:26 +00:00
53 changed files with 1018 additions and 588 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
api.go
View File

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

45
auth.go
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
main.go
View File

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

View File

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

12
package-lock.json generated
View File

@ -4816,9 +4816,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "5.7.1", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"bin": { "bin": {
"semver": "bin/semver" "semver": "bin/semver"
} }
@ -10364,9 +10364,9 @@
} }
}, },
"semver": { "semver": {
"version": "5.7.1", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="
}, },
"send": { "send": {
"version": "0.18.0", "version": "0.18.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

81
ts/modules/captcha.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

122
views.go
View File

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