mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-12-22 09:00:10 +00:00
Merge activity log
Activity log
This commit is contained in:
commit
1be20d471d
186
api-activities.go
Normal file
186
api-activities.go
Normal file
@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
func stringToActivityType(v string) ActivityType {
|
||||
switch v {
|
||||
case "creation":
|
||||
return ActivityCreation
|
||||
case "deletion":
|
||||
return ActivityDeletion
|
||||
case "disabled":
|
||||
return ActivityDisabled
|
||||
case "enabled":
|
||||
return ActivityEnabled
|
||||
case "contactLinked":
|
||||
return ActivityContactLinked
|
||||
case "contactUnlinked":
|
||||
return ActivityContactUnlinked
|
||||
case "changePassword":
|
||||
return ActivityChangePassword
|
||||
case "resetPassword":
|
||||
return ActivityResetPassword
|
||||
case "createInvite":
|
||||
return ActivityCreateInvite
|
||||
case "deleteInvite":
|
||||
return ActivityDeleteInvite
|
||||
}
|
||||
return ActivityUnknown
|
||||
}
|
||||
|
||||
func activityTypeToString(v ActivityType) string {
|
||||
switch v {
|
||||
case ActivityCreation:
|
||||
return "creation"
|
||||
case ActivityDeletion:
|
||||
return "deletion"
|
||||
case ActivityDisabled:
|
||||
return "disabled"
|
||||
case ActivityEnabled:
|
||||
return "enabled"
|
||||
case ActivityContactLinked:
|
||||
return "contactLinked"
|
||||
case ActivityContactUnlinked:
|
||||
return "contactUnlinked"
|
||||
case ActivityChangePassword:
|
||||
return "changePassword"
|
||||
case ActivityResetPassword:
|
||||
return "resetPassword"
|
||||
case ActivityCreateInvite:
|
||||
return "createInvite"
|
||||
case ActivityDeleteInvite:
|
||||
return "deleteInvite"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func stringToActivitySource(v string) ActivitySource {
|
||||
switch v {
|
||||
case "user":
|
||||
return ActivityUser
|
||||
case "admin":
|
||||
return ActivityAdmin
|
||||
case "anon":
|
||||
return ActivityAnon
|
||||
case "daemon":
|
||||
return ActivityDaemon
|
||||
}
|
||||
return ActivityAnon
|
||||
}
|
||||
|
||||
func activitySourceToString(v ActivitySource) string {
|
||||
switch v {
|
||||
case ActivityUser:
|
||||
return "user"
|
||||
case ActivityAdmin:
|
||||
return "admin"
|
||||
case ActivityAnon:
|
||||
return "anon"
|
||||
case ActivityDaemon:
|
||||
return "daemon"
|
||||
}
|
||||
return "anon"
|
||||
}
|
||||
|
||||
// @Summary Get the requested set of activities, Paginated, filtered and sorted.
|
||||
// @Produce json
|
||||
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
|
||||
// @Success 200 {object} GetActivitiesRespDTO
|
||||
// @Router /activity [post]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivities(gc *gin.Context) {
|
||||
req := GetActivitiesDTO{}
|
||||
gc.BindJSON(&req)
|
||||
query := &badgerhold.Query{}
|
||||
activityTypes := make([]interface{}, len(req.Type))
|
||||
for i, v := range req.Type {
|
||||
activityTypes[i] = stringToActivityType(v)
|
||||
}
|
||||
if len(activityTypes) != 0 {
|
||||
query = badgerhold.Where("Type").In(activityTypes...)
|
||||
}
|
||||
|
||||
if !req.Ascending {
|
||||
query = query.Reverse()
|
||||
}
|
||||
|
||||
query = query.SortBy("Time")
|
||||
|
||||
if req.Limit == 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
|
||||
query = query.Skip(req.Page * req.Limit).Limit(req.Limit)
|
||||
|
||||
var results []Activity
|
||||
err := app.storage.db.Find(&results, query)
|
||||
|
||||
if err != nil {
|
||||
app.err.Printf("Failed to read activities from DB: %v\n", err)
|
||||
}
|
||||
|
||||
resp := GetActivitiesRespDTO{
|
||||
Activities: make([]ActivityDTO, len(results)),
|
||||
LastPage: len(results) != req.Limit,
|
||||
}
|
||||
|
||||
for i, act := range results {
|
||||
resp.Activities[i] = ActivityDTO{
|
||||
ID: act.ID,
|
||||
Type: activityTypeToString(act.Type),
|
||||
UserID: act.UserID,
|
||||
SourceType: activitySourceToString(act.SourceType),
|
||||
Source: act.Source,
|
||||
InviteCode: act.InviteCode,
|
||||
Value: act.Value,
|
||||
Time: act.Time.Unix(),
|
||||
}
|
||||
if act.Type == ActivityDeletion || act.Type == ActivityCreation {
|
||||
resp.Activities[i].Username = act.Value
|
||||
resp.Activities[i].Value = ""
|
||||
} else if user, status, err := app.jf.UserByID(act.UserID, false); status == 200 && err == nil {
|
||||
resp.Activities[i].Username = user.Name
|
||||
}
|
||||
|
||||
if (act.SourceType == ActivityUser || act.SourceType == ActivityAdmin) && act.Source != "" {
|
||||
user, status, err := app.jf.UserByID(act.Source, false)
|
||||
if status == 200 && err == nil {
|
||||
resp.Activities[i].SourceUsername = user.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gc.JSON(200, resp)
|
||||
}
|
||||
|
||||
// @Summary Delete the activity with the given ID. No-op if non-existent, always succeeds.
|
||||
// @Produce json
|
||||
// @Param id path string true "ID of activity to delete"
|
||||
// @Success 200 {object} boolResponse
|
||||
// @Router /activity/{id} [delete]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) DeleteActivity(gc *gin.Context) {
|
||||
app.storage.DeleteActivityKey(gc.Param("id"))
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
// @Summary Returns the total number of activities stored in the database.
|
||||
// @Produce json
|
||||
// @Success 200 {object} GetActivityCountDTO
|
||||
// @Router /activity/count [get]
|
||||
// @Security Bearer
|
||||
// @tags Activity
|
||||
func (app *appContext) GetActivityCount(gc *gin.Context) {
|
||||
resp := GetActivityCountDTO{}
|
||||
var err error
|
||||
resp.Count, err = app.storage.db.Count(&Activity{}, &badgerhold.Query{})
|
||||
if err != nil {
|
||||
resp.Count = 0
|
||||
}
|
||||
gc.JSON(200, resp)
|
||||
}
|
@ -85,6 +85,14 @@ func (app *appContext) checkInvites() {
|
||||
wait.Wait()
|
||||
}
|
||||
app.storage.DeleteInvitesKey(data.Code)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: data.Code,
|
||||
Value: data.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,12 +138,26 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
|
||||
}
|
||||
match = false
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
} else if used {
|
||||
del := false
|
||||
newInv := inv
|
||||
if newInv.RemainingUses == 1 {
|
||||
del = true
|
||||
app.storage.DeleteInvitesKey(code)
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityDaemon,
|
||||
InviteCode: code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
} else if newInv.RemainingUses != 0 {
|
||||
// 0 means infinite i guess?
|
||||
newInv.RemainingUses--
|
||||
@ -236,6 +258,18 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
app.storage.SetInvitesKey(invite.Code, invite)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreateInvite,
|
||||
UserID: "",
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: invite.Code,
|
||||
Value: invite.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -429,10 +463,20 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
|
||||
var req deleteInviteDTO
|
||||
gc.BindJSON(&req)
|
||||
app.debug.Printf("%s: Deletion requested", req.Code)
|
||||
var ok bool
|
||||
_, ok = app.storage.GetInvitesKey(req.Code)
|
||||
inv, ok := app.storage.GetInvitesKey(req.Code)
|
||||
if ok {
|
||||
app.storage.DeleteInvitesKey(req.Code)
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeleteInvite,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
InviteCode: req.Code,
|
||||
Value: inv.Label,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
app.info.Printf("%s: Invite deleted", req.Code)
|
||||
respondBool(200, true, gc)
|
||||
return
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@ -677,7 +678,18 @@ func (app *appContext) DiscordConnect(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetDiscordKey(req.JellyfinID, user)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: req.JellyfinID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
linkExistingOmbiDiscordTelegram(app)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@ -697,6 +709,16 @@ func (app *appContext) UnlinkDiscord(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteDiscordKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -715,6 +737,16 @@ func (app *appContext) UnlinkTelegram(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteTelegramKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -733,5 +765,15 @@ func (app *appContext) UnlinkMatrix(gc *gin.Context) {
|
||||
return
|
||||
} */
|
||||
app.storage.DeleteMatrixKey(req.ID)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: req.ID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@ -207,6 +208,16 @@ func (app *appContext) confirmMyAction(gc *gin.Context, key string) {
|
||||
}
|
||||
emailStore.Addr = claims["email"].(string)
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
@ -359,6 +370,16 @@ func (app *appContext) MyDiscordVerifiedInvite(gc *gin.Context) {
|
||||
dcUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetDiscordKey(gc.GetString("jfId"), dcUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -397,6 +418,16 @@ func (app *appContext) MyTelegramVerifiedInvite(gc *gin.Context) {
|
||||
tgUser.Contact = existingUser.Contact
|
||||
}
|
||||
app.storage.SetTelegramKey(gc.GetString("jfId"), tgUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -468,6 +499,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
}
|
||||
|
||||
app.storage.SetMatrixKey(gc.GetString("jfId"), mxUser)
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactLinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
delete(app.matrix.tokens, pin)
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
@ -480,6 +521,16 @@ func (app *appContext) MatrixCheckMyPIN(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
app.storage.DeleteDiscordKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "discord",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -491,6 +542,16 @@ func (app *appContext) UnlinkMyDiscord(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
app.storage.DeleteTelegramKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "telegram",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -502,6 +563,16 @@ func (app *appContext) UnlinkMyTelegram(gc *gin.Context) {
|
||||
// @Tags User Page
|
||||
func (app *appContext) UnlinkMyMatrix(gc *gin.Context) {
|
||||
app.storage.DeleteMatrixKey(gc.GetString("jfId"))
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityContactUnlinked,
|
||||
UserID: gc.GetString("jfId"),
|
||||
SourceType: ActivityUser,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "matrix",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
respondBool(200, true, gc)
|
||||
}
|
||||
|
||||
@ -620,6 +691,15 @@ func (app *appContext) ChangeMyPassword(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityChangePassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
func() {
|
||||
ombiUser, status, err := app.getOmbiUser(gc.GetString("jfId"))
|
||||
|
76
api-users.go
76
api-users.go
@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
@ -45,6 +46,17 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
|
||||
return
|
||||
}
|
||||
id := user.ID
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreation,
|
||||
UserID: id,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
profile := app.storage.GetDefaultProfile()
|
||||
if req.Profile != "" && req.Profile != "none" {
|
||||
if p, ok := app.storage.GetProfileKey(req.Profile); ok {
|
||||
@ -303,6 +315,24 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
}
|
||||
id := user.ID
|
||||
|
||||
// Record activity
|
||||
sourceType := ActivityAnon
|
||||
source := ""
|
||||
if invite.ReferrerJellyfinID != "" {
|
||||
sourceType = ActivityUser
|
||||
source = invite.ReferrerJellyfinID
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityCreation,
|
||||
UserID: id,
|
||||
SourceType: sourceType,
|
||||
Source: source,
|
||||
InviteCode: invite.Code,
|
||||
Value: user.Name,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
emailStore := EmailAddress{
|
||||
Addr: req.Email,
|
||||
Contact: (req.Email != ""),
|
||||
@ -353,6 +383,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
|
||||
if app.storage.deprecatedDiscord == nil {
|
||||
app.storage.deprecatedDiscord = discordStore{}
|
||||
}
|
||||
// Note we don't log an activity here, since it's part of creating a user.
|
||||
app.storage.SetDiscordKey(user.ID, discordUser)
|
||||
delete(app.discord.verifiedTokens, req.DiscordPIN)
|
||||
}
|
||||
@ -539,6 +570,10 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
sendMail = false
|
||||
}
|
||||
}
|
||||
activityType := ActivityDisabled
|
||||
if req.Enabled {
|
||||
activityType = ActivityEnabled
|
||||
}
|
||||
for _, userID := range req.Users {
|
||||
user, status, err := app.jf.UserByID(userID, false)
|
||||
if status != 200 || err != nil {
|
||||
@ -553,6 +588,16 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) {
|
||||
app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: activityType,
|
||||
UserID: userID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
app.err.Printf("Failed to send account enabled/disabled email: %v", err)
|
||||
@ -605,6 +650,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user, status, err := app.jf.UserByID(userID, false); status == 200 && err == nil {
|
||||
username = user.Name
|
||||
}
|
||||
|
||||
status, err := app.jf.DeleteUser(userID)
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
msg := fmt.Sprintf("%d: %v", status, err)
|
||||
@ -614,6 +665,17 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
|
||||
errors[userID] += msg
|
||||
}
|
||||
}
|
||||
|
||||
// Record activity
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityDeletion,
|
||||
UserID: userID,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: username,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if sendMail && req.Notify {
|
||||
if err := app.sendByID(msg, userID); err != nil {
|
||||
app.err.Printf("Failed to send account deletion email: %v", err)
|
||||
@ -1097,6 +1159,20 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
|
||||
|
||||
emailStore.Addr = address
|
||||
app.storage.SetEmailsKey(id, emailStore)
|
||||
|
||||
activityType := ActivityContactLinked
|
||||
if address == "" {
|
||||
activityType = ActivityContactUnlinked
|
||||
}
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: activityType,
|
||||
UserID: id,
|
||||
SourceType: ActivityAdmin,
|
||||
Source: gc.GetString("jfId"),
|
||||
Value: "email",
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
if ombiEnabled {
|
||||
ombiUser, code, err := app.getOmbiUser(id)
|
||||
if code == 200 && err == nil {
|
||||
|
11
api.go
11
api.go
@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/itchyny/timefmt-go"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
@ -157,6 +158,7 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = resp.UsersReset[0]
|
||||
}
|
||||
|
||||
var user mediabrowser.User
|
||||
var status int
|
||||
var err error
|
||||
@ -170,6 +172,15 @@ func (app *appContext) ResetSetPassword(gc *gin.Context) {
|
||||
respondBool(500, false, gc)
|
||||
return
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: user.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: user.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
|
||||
prevPassword := req.PIN
|
||||
if isInternal {
|
||||
prevPassword = ""
|
||||
|
@ -78,6 +78,9 @@ func (app *appContext) loadConfig() error {
|
||||
app.MustSetValue("smtp", "cert_validation", "true")
|
||||
app.MustSetValue("smtp", "auth_type", "4")
|
||||
|
||||
app.MustSetValue("activity_log", "keep_n_records", "1000")
|
||||
app.MustSetValue("activity_log", "delete_after_days", "90")
|
||||
|
||||
sc := app.config.Section("discord").Key("start_command").MustString("start")
|
||||
app.config.Section("discord").Key("start_command").SetValue(strings.TrimPrefix(strings.TrimPrefix(sc, "/"), "!"))
|
||||
|
||||
|
@ -515,6 +515,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity_log": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
"name": "Activity Log",
|
||||
"description": "Settings for data retention of the activity log."
|
||||
},
|
||||
"settings": {
|
||||
"keep_n_records": {
|
||||
"name": "Number of records to keep",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 1000,
|
||||
"description": "How many of the most recent activities to keep. Set to 0 to disable."
|
||||
},
|
||||
"delete_after_days": {
|
||||
"name": "Delete activities older than (days):",
|
||||
"required": false,
|
||||
"requires_restart": true,
|
||||
"type": "number",
|
||||
"value": 90,
|
||||
"description": "If an activity was created this many days ago, it will be deleted. Set to 0 to disable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"captcha": {
|
||||
"order": [],
|
||||
"meta": {
|
||||
|
@ -3,6 +3,10 @@
|
||||
color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.loader.rel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader .dot {
|
||||
--diameter: 0.5rem;
|
||||
--radius: calc(var(--diameter) / 2);
|
||||
@ -15,6 +19,12 @@
|
||||
left: calc(50% - var(--radius));
|
||||
animation: osc 1s cubic-bezier(.72,.16,.31,.97) infinite;
|
||||
}
|
||||
|
||||
.loader.rel .dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.loader.loader-sm .dot {
|
||||
--deviation: 10%;
|
||||
}
|
||||
|
44
daemon.go
44
daemon.go
@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v3"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/timshannon/badgerhold/v4"
|
||||
)
|
||||
|
||||
// clearEmails removes stored emails for users which no longer exist.
|
||||
@ -72,6 +74,37 @@ func (app *appContext) clearTelegram() {
|
||||
}
|
||||
}
|
||||
|
||||
func (app *appContext) clearActivities() {
|
||||
app.debug.Println("Husekeeping: Cleaning up Activity log...")
|
||||
keepCount := app.config.Section("activity_log").Key("keep_n_records").MustInt(1000)
|
||||
maxAgeDays := app.config.Section("activity_log").Key("delete_after_days").MustInt(90)
|
||||
minAge := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||
err := error(nil)
|
||||
errorSource := 0
|
||||
if maxAgeDays != 0 {
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, badgerhold.Where("Time").Lt(minAge))
|
||||
}
|
||||
if err == nil && keepCount != 0 {
|
||||
// app.debug.Printf("Keeping %d records", keepCount)
|
||||
err = app.storage.db.DeleteMatching(&Activity{}, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
if err != nil {
|
||||
errorSource = 1
|
||||
}
|
||||
}
|
||||
if err == badger.ErrTxnTooBig {
|
||||
app.debug.Printf("Activities: Delete txn was too big, doing it manually.")
|
||||
list := []Activity{}
|
||||
if errorSource == 0 {
|
||||
app.storage.db.Find(&list, badgerhold.Where("Time").Lt(minAge))
|
||||
} else {
|
||||
app.storage.db.Find(&list, (&badgerhold.Query{}).Reverse().SortBy("Time").Skip(keepCount))
|
||||
}
|
||||
for _, record := range list {
|
||||
app.storage.DeleteActivityKey(record.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://bbengfort.github.io/snippets/2016/06/26/background-work-goroutines-timer.html THANKS
|
||||
|
||||
type housekeepingDaemon struct {
|
||||
@ -91,10 +124,13 @@ func newInviteDaemon(interval time.Duration, app *appContext) *housekeepingDaemo
|
||||
period: interval,
|
||||
app: app,
|
||||
}
|
||||
daemon.jobs = []func(app *appContext){func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
}}
|
||||
daemon.jobs = []func(app *appContext){
|
||||
func(app *appContext) {
|
||||
app.debug.Println("Housekeeping: Checking for expired invites")
|
||||
app.checkInvites()
|
||||
},
|
||||
func(app *appContext) { app.clearActivities() },
|
||||
}
|
||||
|
||||
clearEmail := app.config.Section("email").Key("require_unique").MustBool(false)
|
||||
clearDiscord := app.config.Section("discord").Key("require_unique").MustBool(false)
|
||||
|
@ -475,6 +475,7 @@
|
||||
<div>
|
||||
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.invites }}</span>
|
||||
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.accounts }}</span>
|
||||
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.activity }}</span>
|
||||
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low mr-2 mb-2 px-5">{{ .strings.settings }}</span>
|
||||
</div>
|
||||
</header>
|
||||
@ -719,6 +720,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-activity" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral activity mb-4 overflow-visible">
|
||||
<div class="flex-expand align-middle">
|
||||
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
|
||||
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0">
|
||||
<span class="h-100 button ~neutral @low center" id="activity-filter-button">{{ .strings.filters }}</span>
|
||||
<div class="dropdown-display">
|
||||
<div class="card ~neutral @low mt-2" id="activity-filter-list">
|
||||
<p class="supra pb-2">{{ .strings.filters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button ~neutral @low ml-2" id="activity-sort-direction">{{ .strings.sortDirection }}</button>
|
||||
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}">
|
||||
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
|
||||
<button class="button ~info @low ml-2" id="activity-refresh" aria-label="{{ .strings.refresh }}" disabled><i class="ri-refresh-line"></i></button>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between py-2">
|
||||
<div class="supra sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div>
|
||||
<div class="supra sm">
|
||||
<span id="activity-total-records" class="mx-2"></span>
|
||||
<span id="activity-loaded-records" class="mx-2"></span>
|
||||
<span id="activity-shown-records" class="mx-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row -mx-2 mb-2">
|
||||
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
|
||||
<span id="activity-filter-area"></span>
|
||||
</div>
|
||||
<div class="my-2">
|
||||
<div id="activity-card-list"></div>
|
||||
<div id="activity-loader"></div>
|
||||
<div class="unfocused h-[100%] my-3" id="activity-not-found">
|
||||
<div class="flex flex-col h-[100%] justify-center items-center">
|
||||
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span>
|
||||
<span class="text-xl font-medium italic mb-3 unfocused" id="activity-keep-searching-description">{{ .strings.keepSearchingDescription }}</span>
|
||||
<div class="flex flex-row">
|
||||
<button class="button ~neutral @low activity-search-clear">
|
||||
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i>
|
||||
</button>
|
||||
<button class="button ~neutral @low unfocused" id="activity-keep-searching">{{ .strings.keepSearching }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-more">{{ .strings.loadMore }}</button>
|
||||
<button class="button m-2 ~neutral @low" id="activity-load-all">{{ .strings.loadAll }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-settings" class="unfocused">
|
||||
<div class="card @low dark:~d_neutral settings overflow">
|
||||
<div class="flex-expand">
|
||||
|
@ -6,6 +6,7 @@
|
||||
"invites": "Invites",
|
||||
"invite": "Invite",
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
@ -54,8 +55,12 @@
|
||||
"reset": "Reset",
|
||||
"donate": "Donate",
|
||||
"unlink": "Unlink Account",
|
||||
"deleted": "Deleted",
|
||||
"disabled": "Disabled",
|
||||
"sendPWR": "Send Password Reset",
|
||||
"noResultsFound": "No Results Found",
|
||||
"keepSearching": "Keep Searching",
|
||||
"keepSearchingDescription": "Only the current loaded activities were searched. Click below if you wish to search all activities.",
|
||||
"contactThrough": "Contact through:",
|
||||
"extendExpiry": "Extend expiry",
|
||||
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
|
||||
@ -118,6 +123,7 @@
|
||||
"accessJFA": "Access jfa-go",
|
||||
"accessJFASettings": "Cannot be changed as either \"Admin Only\" or \"Allow All\" has been set in Settings > General.",
|
||||
"sortingBy": "Sorting By",
|
||||
"sortDirection": "Sort Direction",
|
||||
"filters": "Filters",
|
||||
"clickToRemoveFilter": "Click to remove this filter.",
|
||||
"clearSearch": "Clear search",
|
||||
@ -129,7 +135,47 @@
|
||||
"userPagePage": "User Page: Page",
|
||||
"buildTime": "Build Time",
|
||||
"builtBy": "Built By",
|
||||
"loginNotAdmin": "Not an Admin?"
|
||||
"loginNotAdmin": "Not an Admin?",
|
||||
"referrer": "Referrer",
|
||||
"accountLinked": "{contactMethod} linked: {user}",
|
||||
"accountUnlinked": "{contactMethod} removed: {user}",
|
||||
"accountResetPassword": "{user} reset their password",
|
||||
"accountChangedPassword": "{user} changed their password",
|
||||
"accountCreated": "Account created: {user}",
|
||||
"accountDeleted": "Account deleted: {user}",
|
||||
"accountDisabled": "Account disabled: {user}",
|
||||
"accountReEnabled": "Account re-enabled: {user}",
|
||||
"accountExpired": "Account expired: {user}",
|
||||
"userDeleted": "User was deleted.",
|
||||
"userDisabled": "User was disabled",
|
||||
"inviteCreated": "Invite created: {invite}",
|
||||
"inviteDeleted": "Invite deleted: {invite}",
|
||||
"inviteExpired": "Invite expired: {invite}",
|
||||
"fromInvite": "From Invite",
|
||||
"byAdmin": "By Admin",
|
||||
"byUser": "By User",
|
||||
"byJfaGo": "By jfa-go",
|
||||
"activityID": "Activity ID",
|
||||
"title": "Title",
|
||||
"usersMentioned": "User mentioned",
|
||||
"actor": "Actor",
|
||||
"actorDescription": "The thing that caused this action. \"user\"/\"admin\"/\"daemon\" or a username.",
|
||||
"accountCreationFilter": "Account Creation",
|
||||
"accountDeletionFilter": "Account Deletion",
|
||||
"accountDisabledFilter": "Account Disabled",
|
||||
"accountEnabledFilter": "Account Enabled",
|
||||
"contactLinkedFilter": "Contact Linked",
|
||||
"contactUnlinkedFilter": "Contact Unlinked",
|
||||
"passwordChangeFilter": "Password Changed",
|
||||
"passwordResetFilter": "Password Reset",
|
||||
"inviteCreatedFilter": "Invite Created",
|
||||
"inviteDeletedFilter": "Invite Deleted/Expired",
|
||||
"loadMore": "Load More",
|
||||
"loadAll": "Load All",
|
||||
"noMoreResults": "No more results.",
|
||||
"totalRecords": "{n} Total Records",
|
||||
"loadedRecords": "{n} Loaded",
|
||||
"shownRecords": "{n} Shown"
|
||||
},
|
||||
"notifications": {
|
||||
"changedEmailAddress": "Changed email address of {n}.",
|
||||
@ -145,6 +191,9 @@
|
||||
"telegramVerified": "Telegram account verified.",
|
||||
"accountConnected": "Account connected.",
|
||||
"referralsEnabled": "Referrals enabled.",
|
||||
"activityDeleted": "Activity Deleted.",
|
||||
"errorInviteNoLongerExists": "Invite no longer exists.",
|
||||
"errorInviteNotFound": "Invite not found.",
|
||||
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
|
||||
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
|
||||
"errorSettingsFailed": "Application failed.",
|
||||
@ -166,6 +215,7 @@
|
||||
"errorApplyUpdate": "Failed to apply update, try manually.",
|
||||
"errorCheckUpdate": "Failed to check for update.",
|
||||
"errorNoReferralTemplate": "Profile doesn't contain referral template, add one in settings.",
|
||||
"errorLoadActivities": "Failed to load activities.",
|
||||
"updateAvailable": "A new update is available, check settings.",
|
||||
"noUpdatesAvailable": "No new updates available."
|
||||
},
|
||||
|
3
main.go
3
main.go
@ -638,6 +638,9 @@ func flagPassed(name string) (found bool) {
|
||||
// @tag.name Profiles & Settings
|
||||
// @tag.description Profile and settings related operations.
|
||||
|
||||
// @tag.name Activity
|
||||
// @tag.description Routes related to the activity log.
|
||||
|
||||
// @tag.name Configuration
|
||||
// @tag.description jfa-go settings.
|
||||
|
||||
|
29
models.go
29
models.go
@ -430,3 +430,32 @@ type GetMyReferralRespDTO struct {
|
||||
type EnableDisableReferralDTO struct {
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
type ActivityDTO struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
SourceType string `json:"source_type"`
|
||||
Source string `json:"source"`
|
||||
SourceUsername string `json:"source_username"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Value string `json:"value"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type GetActivitiesDTO struct {
|
||||
Type []string `json:"type"` // Types of activity to get. Leave blank for all.
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"` // zero-indexed
|
||||
Ascending bool `json:"ascending"`
|
||||
}
|
||||
|
||||
type GetActivitiesRespDTO struct {
|
||||
Activities []ActivityDTO `json:"activities"`
|
||||
LastPage bool `json:"last_page"`
|
||||
}
|
||||
|
||||
type GetActivityCountDTO struct {
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
@ -118,6 +118,9 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
|
||||
router.GET(p+"/accounts", app.AdminPage)
|
||||
router.GET(p+"/settings", app.AdminPage)
|
||||
router.GET(p+"/activity", app.AdminPage)
|
||||
router.GET(p+"/accounts/user/:userID", app.AdminPage)
|
||||
router.GET(p+"/invites/:code", app.AdminPage)
|
||||
router.GET(p+"/lang/:page/:file", app.ServeLang)
|
||||
router.GET(p+"/token/login", app.getTokenLogin)
|
||||
router.GET(p+"/token/refresh", app.getTokenRefresh)
|
||||
@ -232,6 +235,10 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
||||
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
|
||||
}
|
||||
|
||||
api.POST(p+"/activity", app.GetActivities)
|
||||
api.DELETE(p+"/activity/:id", app.DeleteActivity)
|
||||
api.GET(p+"/activity/count", app.GetActivityCount)
|
||||
|
||||
if userPageEnabled {
|
||||
user.GET("/details", app.MyDetails)
|
||||
user.POST("/contact", app.SetMyContactMethods)
|
||||
|
62
storage.go
62
storage.go
@ -21,6 +21,42 @@ type telegramStore map[string]TelegramUser
|
||||
type matrixStore map[string]MatrixUser
|
||||
type emailStore map[string]EmailAddress
|
||||
|
||||
type ActivityType int
|
||||
|
||||
const (
|
||||
ActivityCreation ActivityType = iota
|
||||
ActivityDeletion
|
||||
ActivityDisabled
|
||||
ActivityEnabled
|
||||
ActivityContactLinked
|
||||
ActivityContactUnlinked
|
||||
ActivityChangePassword
|
||||
ActivityResetPassword
|
||||
ActivityCreateInvite
|
||||
ActivityDeleteInvite
|
||||
ActivityUnknown
|
||||
)
|
||||
|
||||
type ActivitySource int
|
||||
|
||||
const (
|
||||
ActivityUser ActivitySource = iota // Source = UserID. For ActivityCreation, this would mean the referrer.
|
||||
ActivityAdmin // Source = Admin's UserID, or blank if jellyfin login isn't on.
|
||||
ActivityAnon // Source = Blank, or potentially browser info. For ActivityCreation, this would be via an invite
|
||||
ActivityDaemon // Source = Blank, was deleted/disabled due to expiry by daemon
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
ID string `badgerhold:"key"`
|
||||
Type ActivityType `badgerhold:"index"`
|
||||
UserID string // ID of target user. For account creation, this will be the newly created account
|
||||
SourceType ActivitySource
|
||||
Source string
|
||||
InviteCode string // Set for ActivityCreation, create/deleteInvite
|
||||
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username.
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
type UserExpiry struct {
|
||||
JellyfinID string `badgerhold:"key"`
|
||||
Expiry time.Time
|
||||
@ -514,6 +550,32 @@ func (st *Storage) DeleteCustomContentKey(k string) {
|
||||
st.db.Delete(k, CustomContent{})
|
||||
}
|
||||
|
||||
// GetActivityKey returns the value stored in the store's key.
|
||||
func (st *Storage) GetActivityKey(k string) (Activity, bool) {
|
||||
result := Activity{}
|
||||
err := st.db.Get(k, &result)
|
||||
ok := true
|
||||
if err != nil {
|
||||
// fmt.Printf("Failed to find custom content: %v\n", err)
|
||||
ok = false
|
||||
}
|
||||
return result, ok
|
||||
}
|
||||
|
||||
// SetActivityKey stores value v in key k.
|
||||
func (st *Storage) SetActivityKey(k string, v Activity) {
|
||||
v.ID = k
|
||||
err := st.db.Upsert(k, v)
|
||||
if err != nil {
|
||||
// fmt.Printf("Failed to set custom content: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteActivityKey deletes value at key k.
|
||||
func (st *Storage) DeleteActivityKey(k string) {
|
||||
st.db.Delete(k, Activity{})
|
||||
}
|
||||
|
||||
type TelegramUser struct {
|
||||
JellyfinID string `badgerhold:"key"`
|
||||
ChatID int64 `badgerhold:"index"`
|
||||
|
32
ts/admin.ts
32
ts/admin.ts
@ -5,6 +5,7 @@ import { Tabs } from "./modules/tabs.js";
|
||||
import { inviteList, createInvite } from "./modules/invites.js";
|
||||
import { accountsList } from "./modules/accounts.js";
|
||||
import { settingsList } from "./modules/settings.js";
|
||||
import { activityList } from "./modules/activity.js";
|
||||
import { ProfileEditor } from "./modules/profiles.js";
|
||||
import { _get, _post, notificationBox, whichAnimationEvent } from "./modules/common.js";
|
||||
import { Updater } from "./modules/update.js";
|
||||
@ -89,6 +90,8 @@ var inviteCreator = new createInvite();
|
||||
|
||||
var accounts = new accountsList();
|
||||
|
||||
var activity = new activityList();
|
||||
|
||||
window.invites = new inviteList();
|
||||
|
||||
var settings = new settingsList();
|
||||
@ -120,6 +123,10 @@ const tabs: { url: string, reloader: () => void }[] = [
|
||||
url: "accounts",
|
||||
reloader: accounts.reload
|
||||
},
|
||||
{
|
||||
url: "activity",
|
||||
reloader: activity.reload
|
||||
},
|
||||
{
|
||||
url: "settings",
|
||||
reloader: settings.reload
|
||||
@ -137,6 +144,9 @@ for (let tab of tabs) {
|
||||
}
|
||||
}
|
||||
|
||||
let isInviteURL = window.invites.isInviteURL();
|
||||
let isAccountURL = accounts.isAccountURL();
|
||||
|
||||
// Default tab
|
||||
if ((window.URLBase + "/").includes(window.location.pathname)) {
|
||||
window.tabs.switch(defaultTab.url, true);
|
||||
@ -146,7 +156,9 @@ document.addEventListener("tab-change", (event: CustomEvent) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const lang = urlParams.get('lang');
|
||||
let tab = window.URLBase + "/" + event.detail;
|
||||
if (tab == window.URLBase + "/invites") {
|
||||
if (event.detail == "") {
|
||||
tab = window.location.pathname;
|
||||
} else if (tab == window.URLBase + "/invites") {
|
||||
if (window.location.pathname == window.URLBase + "/") {
|
||||
tab = window.URLBase + "/";
|
||||
} else if (window.URLBase) { tab = window.URLBase; }
|
||||
@ -167,6 +179,7 @@ const login = new Login(window.modals.login as Modal, "/", window.loginAppearanc
|
||||
login.onLogin = () => {
|
||||
console.log("Logged in.");
|
||||
window.updater = new Updater();
|
||||
// FIXME: Decide whether to autoload activity or not
|
||||
setInterval(() => { window.invites.reload(); accounts.reload(); }, 30*1000);
|
||||
const currentTab = window.tabs.current;
|
||||
switch (currentTab) {
|
||||
@ -179,6 +192,23 @@ login.onLogin = () => {
|
||||
case "settings":
|
||||
settings.reload();
|
||||
break;
|
||||
case "activity": // FIXME: fix URL clash with route
|
||||
activity.reload();
|
||||
break;
|
||||
default:
|
||||
console.log(isAccountURL, isInviteURL);
|
||||
if (isInviteURL) {
|
||||
window.invites.reload(() => {
|
||||
window.invites.loadInviteURL();
|
||||
window.tabs.switch("invites", false, true);
|
||||
});
|
||||
} else if (isAccountURL) {
|
||||
accounts.reload(() => {
|
||||
accounts.loadAccountURL();
|
||||
window.tabs.switch("accounts", false, true);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ import { templateEmail } from "../modules/settings.js";
|
||||
import { Marked } from "@ts-stack/markdown";
|
||||
import { stripMarkdown } from "../modules/stripmd.js";
|
||||
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
const dateParser = require("any-date-parser");
|
||||
|
||||
interface User {
|
||||
@ -39,7 +40,7 @@ interface announcementTemplate {
|
||||
|
||||
var addDiscord: (passData: string) => void;
|
||||
|
||||
class user implements User {
|
||||
class user implements User, SearchableItem {
|
||||
private _id = "";
|
||||
private _row: HTMLTableRowElement;
|
||||
private _check: HTMLInputElement;
|
||||
@ -73,6 +74,8 @@ class user implements User {
|
||||
private _referralsEnabled: boolean;
|
||||
private _referralsEnabledCheck: HTMLElement;
|
||||
|
||||
focus = () => this._row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
lastNotifyMethod = (): string => {
|
||||
// Telegram, Matrix, Discord
|
||||
const telegram = window.telegramEnabled && this._telegramUsername && this._telegramUsername != "";
|
||||
@ -269,7 +272,7 @@ class user implements User {
|
||||
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span>
|
||||
<input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
|
||||
} else {
|
||||
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
|
||||
@ -780,13 +783,13 @@ export class accountsList {
|
||||
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
|
||||
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
|
||||
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
|
||||
private _search = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement;
|
||||
private _search: Search;
|
||||
|
||||
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
|
||||
private _users: { [id: string]: user };
|
||||
private _ordering: string[] = [];
|
||||
private _checkCount: number = 0;
|
||||
private _inSearch = false;
|
||||
// Whether the enable/disable button should enable or not.
|
||||
private _shouldEnable = false;
|
||||
|
||||
@ -836,7 +839,7 @@ export class accountsList {
|
||||
}
|
||||
}
|
||||
|
||||
private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
|
||||
private _queries: { [field: string]: QueryType } = {
|
||||
"id": {
|
||||
// We don't use a translation here to circumvent the name substitution feature.
|
||||
name: "Jellyfin/Emby ID",
|
||||
@ -887,7 +890,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-access-jfa"
|
||||
dependsOnElement: ".accounts-header-access-jfa"
|
||||
},
|
||||
"email": {
|
||||
name: window.lang.strings("emailAddress"),
|
||||
@ -895,7 +898,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-email"
|
||||
dependsOnElement: ".accounts-header-email"
|
||||
},
|
||||
"telegram": {
|
||||
name: "Telegram",
|
||||
@ -903,7 +906,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-telegram"
|
||||
dependsOnElement: ".accounts-header-telegram"
|
||||
},
|
||||
"matrix": {
|
||||
name: "Matrix",
|
||||
@ -911,7 +914,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-matrix"
|
||||
dependsOnElement: ".accounts-header-matrix"
|
||||
},
|
||||
"discord": {
|
||||
name: "Discord",
|
||||
@ -919,7 +922,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-discord"
|
||||
dependsOnElement: ".accounts-header-discord"
|
||||
},
|
||||
"expiry": {
|
||||
name: window.lang.strings("expiry"),
|
||||
@ -927,7 +930,7 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: true,
|
||||
dependsOnTableHeader: "accounts-header-expiry"
|
||||
dependsOnElement: ".accounts-header-expiry"
|
||||
},
|
||||
"last-active": {
|
||||
name: window.lang.strings("lastActiveTime"),
|
||||
@ -942,229 +945,12 @@ export class accountsList {
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false,
|
||||
dependsOnTableHeader: "accounts-header-referrals"
|
||||
dependsOnElement: ".accounts-header-referrals"
|
||||
}
|
||||
}
|
||||
|
||||
private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found");
|
||||
|
||||
search = (query: String): string[] => {
|
||||
console.log(this._queries);
|
||||
this._filterArea.textContent = "";
|
||||
|
||||
query = query.toLowerCase();
|
||||
let result: string[] = [...this._ordering];
|
||||
// console.log("initial:", result);
|
||||
|
||||
// const words = query.split(" ");
|
||||
let words: string[] = [];
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
let lastQuote = -1;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
|
||||
queryStart = i;
|
||||
}
|
||||
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
|
||||
if (lastQuote != -1) {
|
||||
lastQuote = -1;
|
||||
quoteSymbol = ``;
|
||||
} else {
|
||||
lastQuote = i;
|
||||
quoteSymbol = query[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (query[i] == " " || i == query.length-1) {
|
||||
if (lastQuote != -1) {
|
||||
continue;
|
||||
} else {
|
||||
let end = i+1;
|
||||
if (query[i] == " ") {
|
||||
end = i;
|
||||
while (i+1 < query.length && query[i+1] == " ") {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
if (!word.includes(":")) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._queries)) continue;
|
||||
|
||||
const queryFormat = this._queries[split[0]];
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let isBool = false;
|
||||
let boolState = false;
|
||||
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
|
||||
isBool = true;
|
||||
boolState = true;
|
||||
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
|
||||
isBool = true;
|
||||
boolState = false;
|
||||
}
|
||||
if (isBool) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}</span>
|
||||
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._search.value = this._search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
}
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!((value && boolState) || (!value && !boolState))) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._search.value = this._search.value.replace(regex, "");
|
||||
}
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const value = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
if (!(value.includes(split[1]))) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
|
||||
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
|
||||
let unmodifiedValue = split[1];
|
||||
if (compareType != 2) {
|
||||
split[1] = split[1].substring(1);
|
||||
}
|
||||
if (compareType == 2) compareType = 0;
|
||||
|
||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in attempt) attempt.month -= 1;
|
||||
|
||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||
console.log("Read", attempt, "and", date);
|
||||
if ("invalid" in (date as any)) continue;
|
||||
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
|
||||
this._search.value = this._search.value.replace(regex, "");
|
||||
}
|
||||
|
||||
this._search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._users[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(user.prototype, queryFormat.getter).get.call(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
continue;
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
|
||||
|
||||
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
|
||||
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
|
||||
const temp = new Date(date.valueOf());
|
||||
temp.setHours(value.getHours(), value.getMinutes());
|
||||
value = temp;
|
||||
console.log("just hours/minutes workaround, value set to", value);
|
||||
}
|
||||
|
||||
|
||||
let match = true;
|
||||
if (compareType == 0) {
|
||||
for (let pair of getterPairs) {
|
||||
if (pair[0] in attempt) {
|
||||
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (compareType == -1) {
|
||||
match = (value < date);
|
||||
} else if (compareType == 1) {
|
||||
match = (value > date);
|
||||
}
|
||||
if (!match) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
get selectAll(): boolean { return this._selectAll.checked; }
|
||||
set selectAll(state: boolean) {
|
||||
let count = 0;
|
||||
@ -1894,6 +1680,25 @@ export class accountsList {
|
||||
this._addUserProfile.innerHTML = innerHTML;
|
||||
}
|
||||
|
||||
focusAccount = (userID: string) => {
|
||||
console.log("focusing user", userID);
|
||||
this._searchBox.value = `id:"${userID}"`;
|
||||
this._search.onSearchBoxChange();
|
||||
if (userID in this._users) this._users[userID].focus();
|
||||
}
|
||||
|
||||
public static readonly _accountURLEvent = "account-url";
|
||||
registerURLListener = () => document.addEventListener(accountsList._accountURLEvent, (event: CustomEvent) => {
|
||||
this.focusAccount(event.detail);
|
||||
});
|
||||
|
||||
isAccountURL = () => { return window.location.pathname.startsWith(window.URLBase + "/accounts/user/"); }
|
||||
|
||||
loadAccountURL = () => {
|
||||
let userID = window.location.pathname.split(window.URLBase + "/accounts/user/")[1].split("?lang")[0];
|
||||
this.focusAccount(userID);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._populateNumbers();
|
||||
this._users = {};
|
||||
@ -1901,7 +1706,7 @@ export class accountsList {
|
||||
this._selectAll.onchange = () => {
|
||||
this.selectAll = this._selectAll.checked;
|
||||
};
|
||||
document.addEventListener("accounts-reload", this.reload);
|
||||
document.addEventListener("accounts-reload", () => this.reload());
|
||||
document.addEventListener("accountCheckEvent", () => { this._checkCount++; this._checkCheckCount(); });
|
||||
document.addEventListener("accountUncheckEvent", () => { this._checkCount--; this._checkCheckCount(); });
|
||||
this._addUserButton.onclick = () => {
|
||||
@ -2014,34 +1819,23 @@ export class accountsList {
|
||||
this._deleteNotify.checked = false;
|
||||
}*/
|
||||
|
||||
const onchange = () => {
|
||||
const query = this._search.value;
|
||||
if (!query) {
|
||||
// this.setVisibility(this._ordering, true);
|
||||
this._inSearch = false;
|
||||
} else {
|
||||
this._inSearch = true;
|
||||
// this.setVisibility(this.search(query), true);
|
||||
}
|
||||
const results = this.search(query);
|
||||
this.setVisibility(results, true);
|
||||
this._checkCheckCount();
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
filterList: document.getElementById("accounts-filter-list"),
|
||||
search: this._searchBox,
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
clearSearchButtonSelector: ".accounts-search-clear",
|
||||
onSearchCallback: (_0: number, _1: boolean, _2: boolean) => {
|
||||
this._checkCheckCount();
|
||||
}
|
||||
};
|
||||
this._search.oninput = onchange;
|
||||
|
||||
const clearSearchButtons = Array.from(document.getElementsByClassName("accounts-search-clear")) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this._search.value = "";
|
||||
onchange();
|
||||
});
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.items = this._users;
|
||||
|
||||
|
||||
this._announceTextarea.onkeyup = this.loadPreview;
|
||||
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
|
||||
@ -2088,15 +1882,16 @@ export class accountsList {
|
||||
|
||||
document.addEventListener("header-click", (event: CustomEvent) => {
|
||||
this._ordering = this._columns[event.detail].sort(this._users);
|
||||
this._search.ordering = this._ordering;
|
||||
this._activeSortColumn = event.detail;
|
||||
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
|
||||
this._sortingByButton.parentElement.classList.remove("hidden");
|
||||
// console.log("ordering by", event.detail, ": ", this._ordering);
|
||||
if (!(this._inSearch)) {
|
||||
if (!(this._search.inSearch)) {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
} else {
|
||||
const results = this.search(this._search.value);
|
||||
const results = this._search.search(this._searchBox.value);
|
||||
this.setVisibility(results, true);
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
@ -2110,87 +1905,12 @@ export class accountsList {
|
||||
defaultSort();
|
||||
this.showHideSearchOptionsHeader();
|
||||
|
||||
const filterList = document.getElementById("accounts-filter-list");
|
||||
this._search.generateFilterList();
|
||||
|
||||
const fillInFilter = (name: string, value: string, offset?: number) => {
|
||||
this._search.value = name + ":" + value + " " + this._search.value;
|
||||
this._search.focus();
|
||||
let newPos = name.length + 1 + value.length;
|
||||
if (typeof offset !== 'undefined')
|
||||
newPos += offset;
|
||||
this._search.setSelectionRange(newPos, newPos);
|
||||
this._search.oninput(null as any);
|
||||
};
|
||||
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._queries)) {
|
||||
const query = this._queries[queryName];
|
||||
if ("show" in query && !query.show) continue;
|
||||
if ("dependsOnTableHeader" in query && query.dependsOnTableHeader) {
|
||||
const el = document.querySelector("."+query.dependsOnTableHeader);
|
||||
if (el === null) continue;
|
||||
}
|
||||
|
||||
const container = document.createElement("span") as HTMLSpanElement;
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
|
||||
container.innerHTML = `<span class="mr-2">${query.name}</span>`;
|
||||
if (query.bool) {
|
||||
const pos = document.createElement("button") as HTMLButtonElement;
|
||||
pos.type = "button";
|
||||
pos.ariaLabel = `Filter by "${query.name}": True`;
|
||||
pos.classList.add("button", "~positive", "ml-2");
|
||||
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
|
||||
pos.addEventListener("click", () => fillInFilter(queryName, "true"));
|
||||
const neg = document.createElement("button") as HTMLButtonElement;
|
||||
neg.type = "button";
|
||||
neg.ariaLabel = `Filter by "${query.name}": False`;
|
||||
neg.classList.add("button", "~critical", "ml-2");
|
||||
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
|
||||
neg.addEventListener("click", () => fillInFilter(queryName, "false"));
|
||||
|
||||
container.appendChild(pos);
|
||||
container.appendChild(neg);
|
||||
}
|
||||
if (query.string) {
|
||||
const button = document.createElement("button") as HTMLButtonElement;
|
||||
button.type = "button";
|
||||
button.classList.add("button", "~urge", "ml-2");
|
||||
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
|
||||
|
||||
// Position cursor between quotes
|
||||
button.addEventListener("click", () => fillInFilter(queryName, `""`, -1));
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
if (query.date) {
|
||||
const onDate = document.createElement("button") as HTMLButtonElement;
|
||||
onDate.type = "button";
|
||||
onDate.classList.add("button", "~urge", "ml-2");
|
||||
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
|
||||
onDate.addEventListener("click", () => fillInFilter(queryName, `"="`, -1));
|
||||
|
||||
const beforeDate = document.createElement("button") as HTMLButtonElement;
|
||||
beforeDate.type = "button";
|
||||
beforeDate.classList.add("button", "~urge", "ml-2");
|
||||
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
|
||||
beforeDate.addEventListener("click", () => fillInFilter(queryName, `"<"`, -1));
|
||||
|
||||
const afterDate = document.createElement("button") as HTMLButtonElement;
|
||||
afterDate.type = "button";
|
||||
afterDate.classList.add("button", "~urge", "ml-2");
|
||||
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
|
||||
afterDate.addEventListener("click", () => fillInFilter(queryName, `">"`, -1));
|
||||
|
||||
container.appendChild(onDate);
|
||||
container.appendChild(beforeDate);
|
||||
container.appendChild(afterDate);
|
||||
}
|
||||
|
||||
filterList.appendChild(container);
|
||||
}
|
||||
this.registerURLListener();
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
reload = (callback?: () => void) => {
|
||||
_get("/users", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4 && req.status == 200) {
|
||||
// same method as inviteList.reload()
|
||||
@ -2210,11 +1930,12 @@ export class accountsList {
|
||||
}
|
||||
// console.log("reload, so sorting by", this._activeSortColumn);
|
||||
this._ordering = this._columns[this._activeSortColumn].sort(this._users);
|
||||
if (!(this._inSearch)) {
|
||||
this._search.ordering = this._ordering;
|
||||
if (!(this._search.inSearch)) {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
} else {
|
||||
const results = this.search(this._search.value);
|
||||
const results = this._search.search(this._searchBox.value);
|
||||
if (results.length == 0) {
|
||||
this._notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
@ -2223,12 +1944,16 @@ export class accountsList {
|
||||
this.setVisibility(results, true);
|
||||
}
|
||||
this._checkCheckCount();
|
||||
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
this.loadTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
export const accountURLEvent = (id: string) => { return new CustomEvent(accountsList._accountURLEvent, {"detail": id}) };
|
||||
|
||||
type GetterReturnType = Boolean | boolean | String | Number | number;
|
||||
type Getter = () => GetterReturnType;
|
||||
|
||||
|
736
ts/modules/activity.ts
Normal file
736
ts/modules/activity.ts
Normal file
@ -0,0 +1,736 @@
|
||||
import { _get, _post, _delete, toDateString, addLoader, removeLoader } from "../modules/common.js";
|
||||
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
|
||||
import { accountURLEvent } from "../modules/accounts.js";
|
||||
import { inviteURLEvent } from "../modules/invites.js";
|
||||
|
||||
export interface activity {
|
||||
id: string;
|
||||
type: string;
|
||||
user_id: string;
|
||||
source_type: string;
|
||||
source: string;
|
||||
invite_code: string;
|
||||
value: string;
|
||||
time: number;
|
||||
username: string;
|
||||
source_username: string;
|
||||
}
|
||||
|
||||
var activityTypeMoods = {
|
||||
"creation": 1,
|
||||
"deletion": -1,
|
||||
"disabled": -1,
|
||||
"enabled": 1,
|
||||
"contactLinked": 1,
|
||||
"contactUnlinked": -1,
|
||||
"changePassword": 0,
|
||||
"resetPassword": 0,
|
||||
"createInvite": 1,
|
||||
"deleteInvite": -1
|
||||
};
|
||||
|
||||
// var moodColours = ["~warning", "~neutral", "~urge"];
|
||||
|
||||
export var activityReload = new CustomEvent("activity-reload");
|
||||
|
||||
export class Activity implements activity, SearchableItem {
|
||||
private _card: HTMLElement;
|
||||
private _title: HTMLElement;
|
||||
private _time: HTMLElement;
|
||||
private _timeUnix: number;
|
||||
private _sourceType: HTMLElement;
|
||||
private _source: HTMLElement;
|
||||
private _referrer: HTMLElement;
|
||||
private _expiryTypeBadge: HTMLElement;
|
||||
private _delete: HTMLElement;
|
||||
private _act: activity;
|
||||
private _urlBase: string = ((): string => {
|
||||
let link = window.location.href;
|
||||
for (let split of ["#", "?", "/activity"]) {
|
||||
link = link.split(split)[0];
|
||||
}
|
||||
if (link.slice(-1) != "/") { link += "/"; }
|
||||
return link;
|
||||
})();
|
||||
|
||||
_genUserText = (): string => {
|
||||
return `<span class="font-medium">${this._act.username || this._act.user_id.substring(0, 5)}</span>`;
|
||||
}
|
||||
|
||||
_genSrcUserText = (): string => {
|
||||
return `<span class="font-medium">${this._act.source_username || this._act.source.substring(0, 5)}</span>`;
|
||||
}
|
||||
|
||||
_genUserLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.user_id}">${this._genUserText()}</span>`;
|
||||
}
|
||||
|
||||
_genSrcUserLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-user" data-id="${this._act.user_id}" data-href="${this._urlBase}accounts/user/${this._act.source}">${this._genSrcUserText()}</span>`;
|
||||
}
|
||||
|
||||
private _renderInvText = (): string => { return `<span class="font-medium font-mono">${this.value || this.invite_code || "???"}</span>`; }
|
||||
|
||||
private _genInvLink = (): string => {
|
||||
return `<span role="link" tabindex="0" class="hover:underline cursor-pointer activity-pseudo-link-invite" data-id="${this.invite_code}" data-href="${this._urlBase}invites/${this.invite_code}">${this._renderInvText()}</span>`;
|
||||
}
|
||||
|
||||
|
||||
get accountCreation(): boolean { return this.type == "creation"; }
|
||||
get accountDeletion(): boolean { return this.type == "deletion"; }
|
||||
get accountDisabled(): boolean { return this.type == "disabled"; }
|
||||
get accountEnabled(): boolean { return this.type == "enabled"; }
|
||||
get contactLinked(): boolean { return this.type == "contactLinked"; }
|
||||
get contactUnlinked(): boolean { return this.type == "contactUnlinked"; }
|
||||
get passwordChange(): boolean { return this.type == "changePassword"; }
|
||||
get passwordReset(): boolean { return this.type == "resetPassword"; }
|
||||
get inviteCreated(): boolean { return this.type == "createInvite"; }
|
||||
get inviteDeleted(): boolean { return this.type == "deleteInvite"; }
|
||||
|
||||
get mentionedUsers(): string {
|
||||
return (this.username + " " + this.source_username).toLowerCase();
|
||||
}
|
||||
|
||||
get actor(): string {
|
||||
let out = this.source_type + " ";
|
||||
if (this.source_type == "admin" || this.source_type == "user") out += this.source_username;
|
||||
return out.toLowerCase();
|
||||
}
|
||||
|
||||
get referrer(): string {
|
||||
if (this.type != "creation" || this.source_type != "user") return "";
|
||||
return this.source_username.toLowerCase();
|
||||
}
|
||||
|
||||
get type(): string { return this._act.type; }
|
||||
set type(v: string) {
|
||||
this._act.type = v;
|
||||
|
||||
let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative
|
||||
for (let el of [this._card, this._delete]) {
|
||||
el.classList.remove("~warning");
|
||||
el.classList.remove("~neutral");
|
||||
el.classList.remove("~urge");
|
||||
|
||||
if (mood == -1) {
|
||||
el.classList.add("~warning");
|
||||
} else if (mood == 0) {
|
||||
el.classList.add("~neutral");
|
||||
} else if (mood == 1) {
|
||||
el.classList.add("~urge");
|
||||
}
|
||||
}
|
||||
|
||||
/* for (let i = 0; i < moodColours.length; i++) {
|
||||
if (i-1 == mood) this._card.classList.add(moodColours[i]);
|
||||
else this._card.classList.remove(moodColours[i]);
|
||||
} */
|
||||
|
||||
if (this.type == "changePassword" || this.type == "resetPassword") {
|
||||
let innerHTML = ``;
|
||||
if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword");
|
||||
else innerHTML = window.lang.strings("accountResetPassword");
|
||||
innerHTML = innerHTML.replace("{user}", this._genUserLink());
|
||||
this._title.innerHTML = innerHTML;
|
||||
} else if (this.type == "contactLinked" || this.type == "contactUnlinked") {
|
||||
let platform = this.value;
|
||||
if (platform == "email") {
|
||||
platform = window.lang.strings("emailAddress");
|
||||
} else {
|
||||
platform = platform.charAt(0).toUpperCase() + platform.slice(1);
|
||||
}
|
||||
let innerHTML = ``;
|
||||
if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked");
|
||||
else innerHTML = window.lang.strings("accountUnlinked");
|
||||
innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform);
|
||||
this._title.innerHTML = innerHTML;
|
||||
} else if (this.type == "creation") {
|
||||
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink());
|
||||
if (this.source_type == "user") {
|
||||
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span>${this._genSrcUserLink()}`;
|
||||
} else {
|
||||
this._referrer.textContent = ``;
|
||||
}
|
||||
} else if (this.type == "deletion") {
|
||||
if (this.source_type == "daemon") {
|
||||
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText());
|
||||
this._expiryTypeBadge.classList.add("~critical");
|
||||
this._expiryTypeBadge.classList.remove("~info");
|
||||
this._expiryTypeBadge.textContent = window.lang.strings("deleted");
|
||||
} else {
|
||||
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText());
|
||||
}
|
||||
} else if (this.type == "enabled") {
|
||||
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink());
|
||||
} else if (this.type == "disabled") {
|
||||
if (this.source_type == "daemon") {
|
||||
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink());
|
||||
this._expiryTypeBadge.classList.add("~info");
|
||||
this._expiryTypeBadge.classList.remove("~critical");
|
||||
this._expiryTypeBadge.textContent = window.lang.strings("disabled");
|
||||
} else {
|
||||
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink());
|
||||
}
|
||||
} else if (this.type == "createInvite") {
|
||||
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink());
|
||||
} else if (this.type == "deleteInvite") {
|
||||
let innerHTML = ``;
|
||||
if (this.source_type == "daemon") {
|
||||
innerHTML = window.lang.strings("inviteExpired");
|
||||
} else {
|
||||
innerHTML = window.lang.strings("inviteDeleted");
|
||||
}
|
||||
|
||||
this._title.innerHTML = innerHTML.replace("{invite}", this._renderInvText());
|
||||
}
|
||||
}
|
||||
|
||||
get time(): number { return this._timeUnix; }
|
||||
set time(v: number) {
|
||||
this._timeUnix = v;
|
||||
this._time.textContent = toDateString(new Date(v*1000));
|
||||
}
|
||||
|
||||
get source_type(): string { return this._act.source_type; }
|
||||
set source_type(v: string) {
|
||||
this._act.source_type = v;
|
||||
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
|
||||
this._sourceType.textContent = window.lang.strings("fromInvite");
|
||||
} else if (this.source_type == "admin") {
|
||||
this._sourceType.textContent = window.lang.strings("byAdmin");
|
||||
} else if (this.source_type == "user" && this.type != "creation") {
|
||||
this._sourceType.textContent = window.lang.strings("byUser");
|
||||
} else if (this.source_type == "daemon") {
|
||||
this._sourceType.textContent = window.lang.strings("byJfaGo");
|
||||
}
|
||||
}
|
||||
|
||||
get invite_code(): string { return this._act.invite_code; }
|
||||
set invite_code(v: string) {
|
||||
this._act.invite_code = v;
|
||||
}
|
||||
|
||||
get value(): string { return this._act.value; }
|
||||
set value(v: string) {
|
||||
this._act.value = v;
|
||||
}
|
||||
|
||||
get source(): string { return this._act.source; }
|
||||
set source(v: string) {
|
||||
this._act.source = v;
|
||||
if ((this.source_type == "anon" || this.source_type == "user") && this.type == "creation") {
|
||||
this._source.innerHTML = this._genInvLink();
|
||||
} else if ((this.source_type == "admin" || this.source_type == "user") && this._act.source != "" && this._act.source_username != "") {
|
||||
this._source.innerHTML = this._genSrcUserLink();
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string { return this._act.id; }
|
||||
set id(v: string) { this._act.id = v; }
|
||||
|
||||
get user_id(): string { return this._act.user_id; }
|
||||
set user_id(v: string) { this._act.user_id = v; }
|
||||
|
||||
get username(): string { return this._act.username; }
|
||||
set username(v: string) { this._act.username = v; }
|
||||
|
||||
get source_username(): string { return this._act.source_username; }
|
||||
set source_username(v: string) { this._act.source_username = v; }
|
||||
|
||||
get title(): string { return this._title.textContent; }
|
||||
|
||||
matchesSearch = (query: string): boolean => {
|
||||
// console.log(this.title, "matches", query, ":", this.title.includes(query));
|
||||
return (
|
||||
this.title.toLowerCase().includes(query) ||
|
||||
this.username.toLowerCase().includes(query) ||
|
||||
this.source_username.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
constructor(act: activity) {
|
||||
this._card = document.createElement("div");
|
||||
|
||||
this._card.classList.add("card", "@low", "my-2");
|
||||
this._card.innerHTML = `
|
||||
<div class="flex flex-col md:flex-row justify-between mb-2">
|
||||
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span>
|
||||
<div class="flex flex-col flex-none ml-0 md:ml-2">
|
||||
<span class="font-medium md:text-sm text-xs activity-time" aria-label="${window.lang.strings("date")}"></span>
|
||||
<span class="activity-expiry-type badge self-start md:self-end mt-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between">
|
||||
<div>
|
||||
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="content activity-referrer"></span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._title = this._card.querySelector(".activity-title");
|
||||
this._time = this._card.querySelector(".activity-time");
|
||||
this._sourceType = this._card.querySelector(".activity-source-type");
|
||||
this._source = this._card.querySelector(".activity-source");
|
||||
this._referrer = this._card.querySelector(".activity-referrer");
|
||||
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
|
||||
this._delete = this._card.querySelector(".activity-delete");
|
||||
|
||||
document.addEventListener("timefmt-change", () => {
|
||||
this.time = this.time;
|
||||
});
|
||||
|
||||
this._delete.addEventListener("click", this.delete);
|
||||
|
||||
this.update(act);
|
||||
|
||||
const pseudoUsers = this._card.getElementsByClassName("activity-pseudo-link-user") as HTMLCollectionOf<HTMLAnchorElement>;
|
||||
const pseudoInvites = this._card.getElementsByClassName("activity-pseudo-link-invite") as HTMLCollectionOf<HTMLAnchorElement>;
|
||||
|
||||
for (let i = 0; i < pseudoUsers.length; i++) {
|
||||
const navigate = (event: Event) => {
|
||||
event.preventDefault()
|
||||
window.tabs.switch("accounts");
|
||||
document.dispatchEvent(accountURLEvent(pseudoUsers[i].getAttribute("data-id")));
|
||||
window.history.pushState(null, document.title, pseudoUsers[i].getAttribute("data-href"));
|
||||
}
|
||||
pseudoUsers[i].onclick = navigate;
|
||||
pseudoUsers[i].onkeydown = navigate;
|
||||
}
|
||||
for (let i = 0; i < pseudoInvites.length; i++) {
|
||||
const navigate = (event: Event) => {
|
||||
event.preventDefault();
|
||||
window.invites.reload(() => {
|
||||
window.tabs.switch("invites");
|
||||
document.dispatchEvent(inviteURLEvent(pseudoInvites[i].getAttribute("data-id")));
|
||||
window.history.pushState(null, document.title, pseudoInvites[i].getAttribute("data-href"));
|
||||
});
|
||||
}
|
||||
pseudoInvites[i].onclick = navigate;
|
||||
pseudoInvites[i].onkeydown = navigate;
|
||||
}
|
||||
}
|
||||
|
||||
update = (act: activity) => {
|
||||
this._act = act;
|
||||
this.source_type = act.source_type;
|
||||
this.invite_code = act.invite_code;
|
||||
this.time = act.time;
|
||||
this.source = act.source;
|
||||
this.value = act.value;
|
||||
this.type = act.type;
|
||||
}
|
||||
|
||||
delete = () => _delete("/activity/" + this._act.id, null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status == 200) {
|
||||
window.notifications.customSuccess("activityDeleted", window.lang.notif("activityDeleted"));
|
||||
}
|
||||
document.dispatchEvent(activityReload);
|
||||
});
|
||||
|
||||
asElement = () => { return this._card; };
|
||||
}
|
||||
|
||||
interface ActivitiesDTO {
|
||||
activities: activity[];
|
||||
last_page: boolean;
|
||||
}
|
||||
|
||||
export class activityList {
|
||||
private _activityList: HTMLElement;
|
||||
private _activities: { [id: string]: Activity } = {};
|
||||
private _ordering: string[] = [];
|
||||
private _filterArea = document.getElementById("activity-filter-area");
|
||||
private _searchOptionsHeader = document.getElementById("activity-search-options-header");
|
||||
private _sortingByButton = document.getElementById("activity-sort-by-field") as HTMLButtonElement;
|
||||
private _notFoundPanel = document.getElementById("activity-not-found");
|
||||
private _searchBox = document.getElementById("activity-search") as HTMLInputElement;
|
||||
private _sortDirection = document.getElementById("activity-sort-direction") as HTMLButtonElement;
|
||||
private _loader = document.getElementById("activity-loader");
|
||||
private _loadMoreButton = document.getElementById("activity-load-more") as HTMLButtonElement;
|
||||
private _loadAllButton = document.getElementById("activity-load-all") as HTMLButtonElement;
|
||||
private _refreshButton = document.getElementById("activity-refresh") as HTMLButtonElement;
|
||||
private _keepSearchingDescription = document.getElementById("activity-keep-searching-description");
|
||||
private _keepSearchingButton = document.getElementById("activity-keep-searching");
|
||||
|
||||
private _totalRecords = document.getElementById("activity-total-records");
|
||||
private _loadedRecords = document.getElementById("activity-loaded-records");
|
||||
private _shownRecords = document.getElementById("activity-shown-records");
|
||||
|
||||
private _total: number;
|
||||
private _loaded: number;
|
||||
private _shown: number;
|
||||
|
||||
get total(): number { return this._total; }
|
||||
set total(v: number) {
|
||||
this._total = v;
|
||||
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
||||
}
|
||||
|
||||
get loaded(): number { return this._loaded; }
|
||||
set loaded(v: number) {
|
||||
this._loaded = v;
|
||||
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
||||
}
|
||||
|
||||
get shown(): number { return this._shown; }
|
||||
set shown(v: number) {
|
||||
this._shown = v;
|
||||
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
||||
}
|
||||
|
||||
private _search: Search;
|
||||
private _ascending: boolean;
|
||||
private _hasLoaded: boolean;
|
||||
private _lastLoad: number;
|
||||
private _page: number = 0;
|
||||
private _lastPage: boolean;
|
||||
|
||||
|
||||
setVisibility = (activities: string[], visible: boolean) => {
|
||||
this._activityList.textContent = ``;
|
||||
for (let id of this._ordering) {
|
||||
if (visible && activities.indexOf(id) != -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
} else if (!visible && activities.indexOf(id) == -1) {
|
||||
this._activityList.appendChild(this._activities[id].asElement());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reload = () => {
|
||||
this._lastLoad = Date.now();
|
||||
this._lastPage = false;
|
||||
this._loadMoreButton.textContent = window.lang.strings("loadMore");
|
||||
this._loadMoreButton.disabled = false;
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
this._loadAllButton.disabled = false;
|
||||
|
||||
this.total = 0;
|
||||
this.loaded = 0;
|
||||
this.shown = 0;
|
||||
|
||||
// this._page = 0;
|
||||
let limit = 10;
|
||||
if (this._page != 0) {
|
||||
limit *= this._page+1;
|
||||
};
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": limit,
|
||||
"page": 0,
|
||||
"ascending": this.ascending
|
||||
}
|
||||
|
||||
_get("/activity/count", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4 || req.status != 200) return;
|
||||
this.total = req.response["count"] as number;
|
||||
});
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._hasLoaded = true;
|
||||
// Allow refreshes every 15s
|
||||
this._refreshButton.disabled = true;
|
||||
setTimeout(() => this._refreshButton.disabled = false, 15000);
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
// FIXME: Don't destroy everything each reload!
|
||||
this._activities = {};
|
||||
this._ordering = [];
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
this._search.items = this._activities;
|
||||
this._search.ordering = this._ordering;
|
||||
|
||||
this.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch) {
|
||||
this._search.onSearchBoxChange(true);
|
||||
this._loadAllButton.classList.remove("unfocused");
|
||||
} else {
|
||||
this.shown = this.loaded;
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
loadMore = (callback?: () => void, loadAll: boolean = false) => {
|
||||
this._lastLoad = Date.now();
|
||||
this._loadMoreButton.disabled = true;
|
||||
// this._loadAllButton.disabled = true;
|
||||
const timeout = setTimeout(() => {
|
||||
this._loadMoreButton.disabled = false;
|
||||
// this._loadAllButton.disabled = false;
|
||||
}, 1000);
|
||||
this._page += 1;
|
||||
|
||||
let send = {
|
||||
"type": [],
|
||||
"limit": 10,
|
||||
"page": this._page,
|
||||
"ascending": this._ascending
|
||||
};
|
||||
|
||||
// this._activityList.classList.add("unfocused");
|
||||
// addLoader(this._loader, false, true);
|
||||
|
||||
_post("/activity", send, (req: XMLHttpRequest) => {
|
||||
if (req.readyState != 4) return;
|
||||
if (req.status != 200) {
|
||||
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
|
||||
return;
|
||||
}
|
||||
|
||||
let resp = req.response as ActivitiesDTO;
|
||||
|
||||
this._lastPage = resp.last_page;
|
||||
if (this._lastPage) {
|
||||
clearTimeout(timeout);
|
||||
this._loadMoreButton.disabled = true;
|
||||
removeLoader(this._loadAllButton);
|
||||
this._loadAllButton.classList.add("unfocused");
|
||||
this._loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
||||
}
|
||||
|
||||
for (let act of resp.activities) {
|
||||
this._activities[act.id] = new Activity(act);
|
||||
this._ordering.push(act.id);
|
||||
}
|
||||
// this._search.items = this._activities;
|
||||
// this._search.ordering = this._ordering;
|
||||
|
||||
this.loaded = this._ordering.length;
|
||||
|
||||
if (this._search.inSearch || loadAll) {
|
||||
if (this._lastPage) {
|
||||
loadAll = false;
|
||||
}
|
||||
this._search.onSearchBoxChange(true, loadAll);
|
||||
} else {
|
||||
this.setVisibility(this._ordering, true);
|
||||
this._notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
// removeLoader(this._loader);
|
||||
// this._activityList.classList.remove("unfocused");
|
||||
}, true);
|
||||
}
|
||||
|
||||
private _queries: { [field: string]: QueryType } = {
|
||||
"id": {
|
||||
name: window.lang.strings("activityID"),
|
||||
getter: "id",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"title": {
|
||||
name: window.lang.strings("title"),
|
||||
getter: "title",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"user": {
|
||||
name: window.lang.strings("usersMentioned"),
|
||||
getter: "mentionedUsers",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"actor": {
|
||||
name: window.lang.strings("actor"),
|
||||
description: window.lang.strings("actorDescription"),
|
||||
getter: "actor",
|
||||
bool: false,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"referrer": {
|
||||
name: window.lang.strings("referrer"),
|
||||
getter: "referrer",
|
||||
bool: true,
|
||||
string: true,
|
||||
date: false
|
||||
},
|
||||
"date": {
|
||||
name: window.lang.strings("date"),
|
||||
getter: "date",
|
||||
bool: false,
|
||||
string: false,
|
||||
date: true
|
||||
},
|
||||
"account-creation": {
|
||||
name: window.lang.strings("accountCreationFilter"),
|
||||
getter: "accountCreation",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-deletion": {
|
||||
name: window.lang.strings("accountDeletionFilter"),
|
||||
getter: "accountDeletion",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-disabled": {
|
||||
name: window.lang.strings("accountDisabledFilter"),
|
||||
getter: "accountDisabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"account-enabled": {
|
||||
name: window.lang.strings("accountEnabledFilter"),
|
||||
getter: "accountEnabled",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-linked": {
|
||||
name: window.lang.strings("contactLinkedFilter"),
|
||||
getter: "contactLinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"contact-unlinked": {
|
||||
name: window.lang.strings("contactUnlinkedFilter"),
|
||||
getter: "contactUnlinked",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-change": {
|
||||
name: window.lang.strings("passwordChangeFilter"),
|
||||
getter: "passwordChange",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"password-reset": {
|
||||
name: window.lang.strings("passwordResetFilter"),
|
||||
getter: "passwordReset",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-created": {
|
||||
name: window.lang.strings("inviteCreatedFilter"),
|
||||
getter: "inviteCreated",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
},
|
||||
"invite-deleted": {
|
||||
name: window.lang.strings("inviteDeletedFilter"),
|
||||
getter: "inviteDeleted",
|
||||
bool: true,
|
||||
string: false,
|
||||
date: false
|
||||
}
|
||||
};
|
||||
|
||||
get ascending(): boolean { return this._ascending; }
|
||||
set ascending(v: boolean) {
|
||||
this._ascending = v;
|
||||
this._sortDirection.innerHTML = `${window.lang.strings("sortDirection")} <i class="ri-arrow-${v ? "up" : "down"}-s-line ml-2"></i>`;
|
||||
if (this._hasLoaded) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detectScroll = () => {
|
||||
if (!this._hasLoaded) return;
|
||||
// console.log(window.innerHeight + document.documentElement.scrollTop, document.scrollingElement.scrollHeight);
|
||||
if (Math.abs(window.innerHeight + document.documentElement.scrollTop - document.scrollingElement.scrollHeight) < 50) {
|
||||
// window.notifications.customSuccess("scroll", "Reached bottom.");
|
||||
// Wait .5s between loads
|
||||
if (this._lastLoad + 500 > Date.now()) return;
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
private _prevResultCount = 0;
|
||||
|
||||
private _notFoundCallback = (notFound: boolean) => {
|
||||
if (notFound) this._loadMoreButton.classList.add("unfocused");
|
||||
else this._loadMoreButton.classList.remove("unfocused");
|
||||
|
||||
if (notFound && !this._lastPage) {
|
||||
this._keepSearchingButton.classList.remove("unfocused");
|
||||
this._keepSearchingDescription.classList.remove("unfocused");
|
||||
} else {
|
||||
this._keepSearchingButton.classList.add("unfocused");
|
||||
this._keepSearchingDescription.classList.add("unfocused");
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this._activityList = document.getElementById("activity-card-list");
|
||||
document.addEventListener("activity-reload", this.reload);
|
||||
|
||||
let conf: SearchConfiguration = {
|
||||
filterArea: this._filterArea,
|
||||
sortingByButton: this._sortingByButton,
|
||||
searchOptionsHeader: this._searchOptionsHeader,
|
||||
notFoundPanel: this._notFoundPanel,
|
||||
search: this._searchBox,
|
||||
clearSearchButtonSelector: ".activity-search-clear",
|
||||
queries: this._queries,
|
||||
setVisibility: this.setVisibility,
|
||||
filterList: document.getElementById("activity-filter-list"),
|
||||
// notFoundCallback: this._notFoundCallback,
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
||||
this.shown = visibleCount;
|
||||
|
||||
if (this._search.inSearch && !this._lastPage) this._loadAllButton.classList.remove("unfocused");
|
||||
else this._loadAllButton.classList.add("unfocused");
|
||||
|
||||
if (visibleCount < 10 || loadAll) {
|
||||
if (!newItems || this._prevResultCount != visibleCount || (visibleCount == 0 && !this._lastPage) || loadAll) this.loadMore(() => {}, loadAll);
|
||||
}
|
||||
this._prevResultCount = visibleCount;
|
||||
}
|
||||
}
|
||||
this._search = new Search(conf);
|
||||
this._search.generateFilterList();
|
||||
|
||||
this._hasLoaded = false;
|
||||
this.ascending = false;
|
||||
this._sortDirection.addEventListener("click", () => this.ascending = !this.ascending);
|
||||
|
||||
this._loadMoreButton.onclick = () => this.loadMore();
|
||||
this._loadAllButton.onclick = () => {
|
||||
addLoader(this._loadAllButton, true);
|
||||
this.loadMore(() => {}, true);
|
||||
};
|
||||
/* this._keepSearchingButton.onclick = () => {
|
||||
addLoader(this._keepSearchingButton, true);
|
||||
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
||||
}; */
|
||||
this._refreshButton.onclick = this.reload;
|
||||
|
||||
window.onscroll = this.detectScroll;
|
||||
}
|
||||
}
|
@ -199,9 +199,10 @@ export function toggleLoader(el: HTMLElement, small: boolean = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function addLoader(el: HTMLElement, small: boolean = true) {
|
||||
export function addLoader(el: HTMLElement, small: boolean = true, relative: boolean = false) {
|
||||
if (!el.classList.contains("loader")) {
|
||||
el.classList.add("loader");
|
||||
if (relative) el.classList.add("rel");
|
||||
if (small) { el.classList.add("loader-sm"); }
|
||||
const dot = document.createElement("span") as HTMLSpanElement;
|
||||
dot.classList.add("dot")
|
||||
@ -213,6 +214,7 @@ export function removeLoader(el: HTMLElement, small: boolean = true) {
|
||||
if (el.classList.contains("loader")) {
|
||||
el.classList.remove("loader");
|
||||
el.classList.remove("loader-sm");
|
||||
el.classList.remove("rel");
|
||||
const dot = el.querySelector("span.dot");
|
||||
if (dot) { dot.remove(); }
|
||||
}
|
||||
|
@ -261,6 +261,8 @@ class DOMInvite implements Invite {
|
||||
}
|
||||
}
|
||||
|
||||
focus = () => this._container.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
constructor(invite: Invite) {
|
||||
// first create the invite structure, then use our setter methods to fill in the data.
|
||||
this._container = document.createElement('div') as HTMLDivElement;
|
||||
@ -423,6 +425,26 @@ export class inviteList implements inviteList {
|
||||
|
||||
invites: { [code: string]: DOMInvite };
|
||||
|
||||
focusInvite = (inviteCode: string, errorMsg: string = window.lang.notif("errorInviteNoLongerExists")) => {
|
||||
for (let code of Object.keys(this.invites)) {
|
||||
this.invites[code].expanded = code == inviteCode;
|
||||
}
|
||||
if (inviteCode in this.invites) this.invites[inviteCode].focus();
|
||||
else window.notifications.customError("inviteDoesntExistError", errorMsg);
|
||||
};
|
||||
|
||||
public static readonly _inviteURLEvent = "invite-url";
|
||||
registerURLListener = () => document.addEventListener(inviteList._inviteURLEvent, (event: CustomEvent) => {
|
||||
this.focusInvite(event.detail);
|
||||
})
|
||||
|
||||
isInviteURL = () => { return window.location.pathname.startsWith(window.URLBase + "/invites/"); }
|
||||
|
||||
loadInviteURL = () => {
|
||||
let inviteCode = window.location.pathname.split(window.URLBase + "/invites/")[1].split("?lang")[0];
|
||||
this.focusInvite(inviteCode, window.lang.notif("errorInviteNotFound"));
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._list = document.getElementById('invites') as HTMLDivElement;
|
||||
this.empty = true;
|
||||
@ -436,6 +458,8 @@ export class inviteList implements inviteList {
|
||||
this.empty = true;
|
||||
}
|
||||
}, false);
|
||||
|
||||
this.registerURLListener();
|
||||
}
|
||||
|
||||
get empty(): boolean { return this._empty; }
|
||||
@ -468,7 +492,7 @@ export class inviteList implements inviteList {
|
||||
this._list.appendChild(domInv.asElement());
|
||||
}
|
||||
|
||||
reload = () => _get("/invites", null, (req: XMLHttpRequest) => {
|
||||
reload = (callback?: () => void) => _get("/invites", null, (req: XMLHttpRequest) => {
|
||||
if (req.readyState == 4) {
|
||||
let data = req.response;
|
||||
if (req.status == 200) {
|
||||
@ -497,10 +521,13 @@ export class inviteList implements inviteList {
|
||||
this.invites[code].remove();
|
||||
delete this.invites[code];
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) };
|
||||
|
||||
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite {
|
||||
let parsed: Invite = {};
|
||||
|
390
ts/modules/search.ts
Normal file
390
ts/modules/search.ts
Normal file
@ -0,0 +1,390 @@
|
||||
const dateParser = require("any-date-parser");
|
||||
|
||||
export interface QueryType {
|
||||
name: string;
|
||||
description?: string;
|
||||
getter: string;
|
||||
bool: boolean;
|
||||
string: boolean;
|
||||
date: boolean;
|
||||
dependsOnElement?: string; // Format for querySelector
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchConfiguration {
|
||||
filterArea: HTMLElement;
|
||||
sortingByButton: HTMLButtonElement;
|
||||
searchOptionsHeader: HTMLElement;
|
||||
notFoundPanel: HTMLElement;
|
||||
notFoundCallback?: (notFound: boolean) => void;
|
||||
filterList: HTMLElement;
|
||||
clearSearchButtonSelector: string;
|
||||
search: HTMLInputElement;
|
||||
queries: { [field: string]: QueryType };
|
||||
setVisibility: (items: string[], visible: boolean) => void;
|
||||
onSearchCallback: (visibleCount: number, newItems: boolean, loadAll: boolean) => void;
|
||||
loadMore?: () => void;
|
||||
}
|
||||
|
||||
export interface SearchableItem {
|
||||
matchesSearch: (query: string) => boolean;
|
||||
}
|
||||
|
||||
export class Search {
|
||||
private _c: SearchConfiguration;
|
||||
private _ordering: string[] = [];
|
||||
private _items: { [id: string]: SearchableItem };
|
||||
inSearch: boolean;
|
||||
|
||||
search = (query: String): string[] => {
|
||||
this._c.filterArea.textContent = "";
|
||||
|
||||
query = query.toLowerCase();
|
||||
|
||||
let result: string[] = [...this._ordering];
|
||||
let words: string[] = [];
|
||||
|
||||
let quoteSymbol = ``;
|
||||
let queryStart = -1;
|
||||
let lastQuote = -1;
|
||||
for (let i = 0; i < query.length; i++) {
|
||||
if (queryStart == -1 && query[i] != " " && query[i] != `"` && query[i] != `'`) {
|
||||
queryStart = i;
|
||||
}
|
||||
if ((query[i] == `"` || query[i] == `'`) && (quoteSymbol == `` || query[i] == quoteSymbol)) {
|
||||
if (lastQuote != -1) {
|
||||
lastQuote = -1;
|
||||
quoteSymbol = ``;
|
||||
} else {
|
||||
lastQuote = i;
|
||||
quoteSymbol = query[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (query[i] == " " || i == query.length-1) {
|
||||
if (lastQuote != -1) {
|
||||
continue;
|
||||
} else {
|
||||
let end = i+1;
|
||||
if (query[i] == " ") {
|
||||
end = i;
|
||||
while (i+1 < query.length && query[i+1] == " ") {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
words.push(query.substring(queryStart, end).replace(/['"]/g, ""));
|
||||
console.log("pushed", words);
|
||||
queryStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query = "";
|
||||
for (let word of words) {
|
||||
if (!word.includes(":")) {
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
if (!u.matchesSearch(word)) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
|
||||
|
||||
if (!(split[0] in this._c.queries)) continue;
|
||||
|
||||
const queryFormat = this._c.queries[split[0]];
|
||||
|
||||
if (queryFormat.bool) {
|
||||
let isBool = false;
|
||||
let boolState = false;
|
||||
if (split[1] == "true" || split[1] == "yes" || split[1] == "t" || split[1] == "y") {
|
||||
isBool = true;
|
||||
boolState = true;
|
||||
} else if (split[1] == "false" || split[1] == "no" || split[1] == "f" || split[1] == "n") {
|
||||
isBool = true;
|
||||
boolState = false;
|
||||
}
|
||||
if (isBool) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~" + (boolState ? "positive" : "critical"), "@high", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}</span>
|
||||
<i class="text-2xl ri-${boolState? "checkbox" : "close"}-circle-fill"></i>
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
// console.log("is bool, state", boolState);
|
||||
// So removing elements doesn't affect us
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
// console.log("got", queryFormat.getter + ":", value);
|
||||
// Remove from result if not matching query
|
||||
if (!((value && boolState) || (!value && !boolState))) {
|
||||
// console.log("not matching, result is", result);
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (queryFormat.string) {
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "mx-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> "${split[1]}"
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const value = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u).toLowerCase();
|
||||
if (!(value.includes(split[1]))) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (queryFormat.date) {
|
||||
// -1 = Before, 0 = On, 1 = After, 2 = No symbol, assume 0
|
||||
let compareType = (split[1][0] == ">") ? 1 : ((split[1][0] == "<") ? -1 : ((split[1][0] == "=") ? 0 : 2));
|
||||
let unmodifiedValue = split[1];
|
||||
if (compareType != 2) {
|
||||
split[1] = split[1].substring(1);
|
||||
}
|
||||
if (compareType == 2) compareType = 0;
|
||||
|
||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
||||
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||
if ("month" in attempt) attempt.month -= 1;
|
||||
|
||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||
console.log("Read", attempt, "and", date);
|
||||
if ("invalid" in (date as any)) continue;
|
||||
|
||||
const filterCard = document.createElement("span");
|
||||
filterCard.ariaLabel = window.lang.strings("clickToRemoveFilter");
|
||||
filterCard.classList.add("button", "~neutral", "@low", "center", "m-2", "h-full");
|
||||
filterCard.innerHTML = `
|
||||
<span class="font-bold mr-2">${queryFormat.name}:</span> ${(compareType == 1) ? window.lang.strings("after")+" " : ((compareType == -1) ? window.lang.strings("before")+" " : "")}${split[1]}
|
||||
`;
|
||||
|
||||
filterCard.addEventListener("click", () => {
|
||||
for (let quote of [`"`, `'`, ``]) {
|
||||
let regex = new RegExp(split[0] + ":" + quote + unmodifiedValue + quote, "ig");
|
||||
this._c.search.value = this._c.search.value.replace(regex, "");
|
||||
}
|
||||
|
||||
this._c.search.oninput((null as Event));
|
||||
})
|
||||
|
||||
this._c.filterArea.appendChild(filterCard);
|
||||
|
||||
let cachedResult = [...result];
|
||||
for (let id of cachedResult) {
|
||||
const u = this._items[id];
|
||||
const unixValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(u), queryFormat.getter).get.call(u);
|
||||
if (unixValue == 0) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
continue;
|
||||
}
|
||||
let value = new Date(unixValue*1000);
|
||||
|
||||
const getterPairs: [string, () => number][] = [["year", Date.prototype.getFullYear], ["month", Date.prototype.getMonth], ["day", Date.prototype.getDate], ["hour", Date.prototype.getHours], ["minute", Date.prototype.getMinutes]];
|
||||
|
||||
// When doing > or < <time> with no date, we need to ignore the rest of the Date object
|
||||
if (compareType != 0 && Object.keys(attempt).length == 2 && "hour" in attempt && "minute" in attempt) {
|
||||
const temp = new Date(date.valueOf());
|
||||
temp.setHours(value.getHours(), value.getMinutes());
|
||||
value = temp;
|
||||
console.log("just hours/minutes workaround, value set to", value);
|
||||
}
|
||||
|
||||
|
||||
let match = true;
|
||||
if (compareType == 0) {
|
||||
for (let pair of getterPairs) {
|
||||
if (pair[0] in attempt) {
|
||||
if (compareType == 0 && attempt[pair[0]] != pair[1].call(value)) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (compareType == -1) {
|
||||
match = (value < date);
|
||||
} else if (compareType == 1) {
|
||||
match = (value > date);
|
||||
}
|
||||
if (!match) {
|
||||
result.splice(result.indexOf(id), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
showHideSearchOptionsHeader = () => {
|
||||
const sortingBy = !(this._c.sortingByButton.parentElement.classList.contains("hidden"));
|
||||
const hasFilters = this._c.filterArea.textContent != "";
|
||||
console.log("sortingBy", sortingBy, "hasFilters", hasFilters);
|
||||
if (sortingBy || hasFilters) {
|
||||
this._c.searchOptionsHeader.classList.remove("hidden");
|
||||
} else {
|
||||
this._c.searchOptionsHeader.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get items(): { [id: string]: SearchableItem } { return this._items; }
|
||||
set items(v: { [id: string]: SearchableItem }) {
|
||||
this._items = v;
|
||||
}
|
||||
|
||||
get ordering(): string[] { return this._ordering; }
|
||||
set ordering(v: string[]) { this._ordering = v; }
|
||||
|
||||
onSearchBoxChange = (newItems: boolean = false, loadAll: boolean = false) => {
|
||||
const query = this._c.search.value;
|
||||
if (!query) {
|
||||
this.inSearch = false;
|
||||
} else {
|
||||
this.inSearch = true;
|
||||
}
|
||||
const results = this.search(query);
|
||||
this._c.setVisibility(results, true);
|
||||
this._c.onSearchCallback(results.length, newItems, loadAll);
|
||||
this.showHideSearchOptionsHeader();
|
||||
if (results.length == 0) {
|
||||
this._c.notFoundPanel.classList.remove("unfocused");
|
||||
} else {
|
||||
this._c.notFoundPanel.classList.add("unfocused");
|
||||
}
|
||||
if (this._c.notFoundCallback) this._c.notFoundCallback(results.length == 0);
|
||||
}
|
||||
|
||||
fillInFilter = (name: string, value: string, offset?: number) => {
|
||||
this._c.search.value = name + ":" + value + " " + this._c.search.value;
|
||||
this._c.search.focus();
|
||||
let newPos = name.length + 1 + value.length;
|
||||
if (typeof offset !== 'undefined')
|
||||
newPos += offset;
|
||||
this._c.search.setSelectionRange(newPos, newPos);
|
||||
this._c.search.oninput(null as any);
|
||||
};
|
||||
|
||||
|
||||
|
||||
generateFilterList = () => {
|
||||
// Generate filter buttons
|
||||
for (let queryName of Object.keys(this._c.queries)) {
|
||||
const query = this._c.queries[queryName];
|
||||
if ("show" in query && !query.show) continue;
|
||||
if ("dependsOnElement" in query && query.dependsOnElement) {
|
||||
const el = document.querySelector(query.dependsOnElement);
|
||||
if (el === null) continue;
|
||||
}
|
||||
|
||||
const container = document.createElement("span") as HTMLSpanElement;
|
||||
container.classList.add("button", "button-xl", "~neutral", "@low", "mb-1", "mr-2");
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col mr-2">
|
||||
<span>${query.name}</span>
|
||||
<span class="support">${query.description || ""}</span>
|
||||
</div>
|
||||
`;
|
||||
if (query.bool) {
|
||||
const pos = document.createElement("button") as HTMLButtonElement;
|
||||
pos.type = "button";
|
||||
pos.ariaLabel = `Filter by "${query.name}": True`;
|
||||
pos.classList.add("button", "~positive", "ml-2");
|
||||
pos.innerHTML = `<i class="ri-checkbox-circle-fill"></i>`;
|
||||
pos.addEventListener("click", () => this.fillInFilter(queryName, "true"));
|
||||
const neg = document.createElement("button") as HTMLButtonElement;
|
||||
neg.type = "button";
|
||||
neg.ariaLabel = `Filter by "${query.name}": False`;
|
||||
neg.classList.add("button", "~critical", "ml-2");
|
||||
neg.innerHTML = `<i class="ri-close-circle-fill"></i>`;
|
||||
neg.addEventListener("click", () => this.fillInFilter(queryName, "false"));
|
||||
|
||||
container.appendChild(pos);
|
||||
container.appendChild(neg);
|
||||
}
|
||||
if (query.string) {
|
||||
const button = document.createElement("button") as HTMLButtonElement;
|
||||
button.type = "button";
|
||||
button.classList.add("button", "~urge", "ml-2");
|
||||
button.innerHTML = `<i class="ri-equal-line mr-2"></i>${window.lang.strings("matchText")}`;
|
||||
|
||||
// Position cursor between quotes
|
||||
button.addEventListener("click", () => this.fillInFilter(queryName, `""`, -1));
|
||||
|
||||
container.appendChild(button);
|
||||
}
|
||||
if (query.date) {
|
||||
const onDate = document.createElement("button") as HTMLButtonElement;
|
||||
onDate.type = "button";
|
||||
onDate.classList.add("button", "~urge", "ml-2");
|
||||
onDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>On Date`;
|
||||
onDate.addEventListener("click", () => this.fillInFilter(queryName, `"="`, -1));
|
||||
|
||||
const beforeDate = document.createElement("button") as HTMLButtonElement;
|
||||
beforeDate.type = "button";
|
||||
beforeDate.classList.add("button", "~urge", "ml-2");
|
||||
beforeDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>Before Date`;
|
||||
beforeDate.addEventListener("click", () => this.fillInFilter(queryName, `"<"`, -1));
|
||||
|
||||
const afterDate = document.createElement("button") as HTMLButtonElement;
|
||||
afterDate.type = "button";
|
||||
afterDate.classList.add("button", "~urge", "ml-2");
|
||||
afterDate.innerHTML = `<i class="ri-calendar-check-line mr-2"></i>After Date`;
|
||||
afterDate.addEventListener("click", () => this.fillInFilter(queryName, `">"`, -1));
|
||||
|
||||
container.appendChild(onDate);
|
||||
container.appendChild(beforeDate);
|
||||
container.appendChild(afterDate);
|
||||
}
|
||||
|
||||
this._c.filterList.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(c: SearchConfiguration) {
|
||||
this._c = c;
|
||||
|
||||
this._c.search.oninput = () => this.onSearchBoxChange();
|
||||
|
||||
const clearSearchButtons = Array.from(document.querySelectorAll(this._c.clearSearchButtonSelector)) as Array<HTMLSpanElement>;
|
||||
for (let b of clearSearchButtons) {
|
||||
b.addEventListener("click", () => {
|
||||
this._c.search.value = "";
|
||||
this.onSearchBoxChange();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ export class Tabs implements Tabs {
|
||||
get current(): string { return this._current; }
|
||||
set current(tabID: string) { this.switch(tabID); }
|
||||
|
||||
switch = (tabID: string, noRun: boolean = false) => {
|
||||
switch = (tabID: string, noRun: boolean = false, keepURL: boolean = false) => {
|
||||
this._current = tabID;
|
||||
for (let t of this.tabs) {
|
||||
if (t.tabID == tabID) {
|
||||
@ -28,7 +28,7 @@ export class Tabs implements Tabs {
|
||||
if (t.preFunc && !noRun) { t.preFunc(); }
|
||||
t.tabEl.classList.remove("unfocused");
|
||||
if (t.postFunc && !noRun) { t.postFunc(); }
|
||||
document.dispatchEvent(new CustomEvent("tab-change", { detail: tabID }));
|
||||
document.dispatchEvent(new CustomEvent("tab-change", { detail: keepURL ? "" : tabID }));
|
||||
} else {
|
||||
t.buttonEl.classList.remove("active");
|
||||
t.buttonEl.classList.remove("~urge");
|
||||
|
@ -80,7 +80,7 @@ declare interface Tabs {
|
||||
current: string;
|
||||
tabs: Array<Tab>;
|
||||
addTab: (tabID: string, preFunc?: () => void, postFunc?: () => void) => void;
|
||||
switch: (tabID: string, noRun?: boolean) => void;
|
||||
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
|
||||
}
|
||||
|
||||
declare interface Tab {
|
||||
@ -139,7 +139,9 @@ interface inviteList {
|
||||
empty: boolean;
|
||||
invites: { [code: string]: Invite }
|
||||
add: (invite: Invite) => void;
|
||||
reload: () => void;
|
||||
reload: (callback?: () => void) => void;
|
||||
isInviteURL: () => boolean;
|
||||
loadInviteURL: () => void;
|
||||
}
|
||||
|
||||
// Finally added to typescript, dont need this anymore.
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
)
|
||||
|
||||
type userDaemon struct {
|
||||
@ -60,10 +61,10 @@ func (app *appContext) checkUsers() {
|
||||
return
|
||||
}
|
||||
mode := "disable"
|
||||
termPlural := "Disabling"
|
||||
term := "Disabling"
|
||||
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
|
||||
mode = "delete"
|
||||
termPlural = "Deleting"
|
||||
term = "Deleting"
|
||||
}
|
||||
contact := false
|
||||
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
|
||||
@ -94,19 +95,33 @@ func (app *appContext) checkUsers() {
|
||||
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
|
||||
continue
|
||||
}
|
||||
app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
|
||||
app.info.Printf("%s expired user \"%s\"", term, user.Name)
|
||||
|
||||
// Record activity
|
||||
activity := Activity{
|
||||
UserID: id,
|
||||
SourceType: ActivityDaemon,
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
if mode == "delete" {
|
||||
status, err = app.jf.DeleteUser(id)
|
||||
activity.Type = ActivityDeletion
|
||||
activity.Value = user.Name
|
||||
} else if mode == "disable" {
|
||||
user.Policy.IsDisabled = true
|
||||
// Admins can't be disabled
|
||||
user.Policy.IsAdministrator = false
|
||||
status, err = app.jf.SetPolicy(id, user.Policy)
|
||||
activity.Type = ActivityDisabled
|
||||
}
|
||||
if !(status == 200 || status == 204) || err != nil {
|
||||
app.err.Printf("Failed to %s \"%s\" (%d): %s", mode, user.Name, status, err)
|
||||
continue
|
||||
}
|
||||
|
||||
app.storage.SetActivityKey(shortuuid.New(), activity)
|
||||
|
||||
app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
|
||||
app.jf.CacheExpiry = time.Now()
|
||||
if contact {
|
||||
|
21
views.go
21
views.go
@ -17,6 +17,7 @@ import (
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/hrfee/mediabrowser"
|
||||
"github.com/lithammer/shortuuid/v3"
|
||||
"github.com/steambap/captcha"
|
||||
)
|
||||
|
||||
@ -38,6 +39,10 @@ func (app *appContext) loadCSSHeader() string {
|
||||
|
||||
func (app *appContext) getURLBase(gc *gin.Context) string {
|
||||
if strings.HasPrefix(gc.Request.URL.String(), app.URLBase) {
|
||||
// Hack to fix the common URL base /accounts
|
||||
if app.URLBase == "/accounts" && strings.HasPrefix(gc.Request.URL.String(), "/accounts/user/") {
|
||||
return ""
|
||||
}
|
||||
return app.URLBase
|
||||
}
|
||||
return ""
|
||||
@ -329,6 +334,7 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
|
||||
}
|
||||
username = pwr.Username
|
||||
}
|
||||
|
||||
if (status == 200 || status == 204) && err == nil && (isInternal || resp.Success) {
|
||||
data["success"] = true
|
||||
data["pin"] = pin
|
||||
@ -338,6 +344,21 @@ func (app *appContext) ResetPassword(gc *gin.Context) {
|
||||
} else {
|
||||
app.err.Printf("Password Reset failed (%d): %v", status, err)
|
||||
}
|
||||
|
||||
// Only log PWRs we know the user for.
|
||||
if username != "" {
|
||||
jfUser, status, err := app.jf.UserByName(username, false)
|
||||
if err == nil && status == 200 {
|
||||
app.storage.SetActivityKey(shortuuid.New(), Activity{
|
||||
Type: ActivityResetPassword,
|
||||
UserID: jfUser.ID,
|
||||
SourceType: ActivityUser,
|
||||
Source: jfUser.ID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if app.config.Section("ombi").Key("enabled").MustBool(false) {
|
||||
jfUser, status, err := app.jf.UserByName(username, false)
|
||||
if status != 200 || err != nil {
|
||||
|
Loading…
Reference in New Issue
Block a user