1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-29 12:30:11 +00:00

Compare commits

..

No commits in common. "a0db685af2337a1bdfb24008c3a7817b40afb1fc" and "a73dfddd3f50e7a613e29cf822b0013b0eac0a75" have entirely different histories.

12 changed files with 445 additions and 876 deletions

View File

@ -89,7 +89,7 @@ func activitySourceToString(v ActivitySource) string {
// @Produce json // @Produce json
// @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters" // @Param GetActivitiesDTO body GetActivitiesDTO true "search parameters"
// @Success 200 {object} GetActivitiesRespDTO // @Success 200 {object} GetActivitiesRespDTO
// @Router /activity [post] // @Router /activity [get]
// @Security Bearer // @Security Bearer
// @tags Activity // @tags Activity
func (app *appContext) GetActivities(gc *gin.Context) { func (app *appContext) GetActivities(gc *gin.Context) {
@ -138,32 +138,7 @@ func (app *appContext) GetActivities(gc *gin.Context) {
Value: act.Value, Value: act.Value,
Time: act.Time.Unix(), 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) 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)
}

View File

@ -90,7 +90,6 @@ func (app *appContext) checkInvites() {
Type: ActivityDeleteInvite, Type: ActivityDeleteInvite,
SourceType: ActivityDaemon, SourceType: ActivityDaemon,
InviteCode: data.Code, InviteCode: data.Code,
Value: data.Label,
Time: time.Now(), Time: time.Now(),
}) })
} }
@ -142,7 +141,6 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
Type: ActivityDeleteInvite, Type: ActivityDeleteInvite,
SourceType: ActivityDaemon, SourceType: ActivityDaemon,
InviteCode: code, InviteCode: code,
Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}) })
} else if used { } else if used {
@ -155,7 +153,6 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool
Type: ActivityDeleteInvite, Type: ActivityDeleteInvite,
SourceType: ActivityDaemon, SourceType: ActivityDaemon,
InviteCode: code, InviteCode: code,
Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}) })
} else if newInv.RemainingUses != 0 { } else if newInv.RemainingUses != 0 {
@ -463,7 +460,8 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
var req deleteInviteDTO var req deleteInviteDTO
gc.BindJSON(&req) gc.BindJSON(&req)
app.debug.Printf("%s: Deletion requested", req.Code) app.debug.Printf("%s: Deletion requested", req.Code)
inv, ok := app.storage.GetInvitesKey(req.Code) var ok bool
_, ok = app.storage.GetInvitesKey(req.Code)
if ok { if ok {
app.storage.DeleteInvitesKey(req.Code) app.storage.DeleteInvitesKey(req.Code)
@ -472,8 +470,6 @@ func (app *appContext) DeleteInvite(gc *gin.Context) {
Type: ActivityDeleteInvite, Type: ActivityDeleteInvite,
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
InviteCode: req.Code,
Value: inv.Label,
Time: time.Now(), Time: time.Now(),
}) })

View File

@ -53,7 +53,6 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) {
UserID: id, UserID: id,
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: user.Name,
Time: time.Now(), Time: time.Now(),
}) })
@ -329,7 +328,6 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc
SourceType: sourceType, SourceType: sourceType,
Source: source, Source: source,
InviteCode: invite.Code, InviteCode: invite.Code,
Value: user.Name,
Time: time.Now(), Time: time.Now(),
}) })
@ -650,12 +648,6 @@ 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) status, err := app.jf.DeleteUser(userID)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
msg := fmt.Sprintf("%d: %v", status, err) msg := fmt.Sprintf("%d: %v", status, err)
@ -672,7 +664,6 @@ func (app *appContext) DeleteUsers(gc *gin.Context) {
UserID: userID, UserID: userID,
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),
Value: username,
Time: time.Now(), Time: time.Now(),
}) })
@ -1160,12 +1151,8 @@ func (app *appContext) ModifyEmails(gc *gin.Context) {
emailStore.Addr = address emailStore.Addr = address
app.storage.SetEmailsKey(id, emailStore) app.storage.SetEmailsKey(id, emailStore)
activityType := ActivityContactLinked
if address == "" {
activityType = ActivityContactUnlinked
}
app.storage.SetActivityKey(shortuuid.New(), Activity{ app.storage.SetActivityKey(shortuuid.New(), Activity{
Type: activityType, Type: ActivityContactLinked,
UserID: id, UserID: id,
SourceType: ActivityAdmin, SourceType: ActivityAdmin,
Source: gc.GetString("jfId"), Source: gc.GetString("jfId"),

View File

@ -725,20 +725,48 @@
<div class="flex-expand align-middle"> <div class="flex-expand align-middle">
<span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span> <span class="text-3xl font-bold mr-4">{{ .strings.activity }}</span>
<div id="activity-filter-dropdown" class="dropdown z-10" tabindex="0"> <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> <span class="h-100 button ~neutral @low center" id="accounts-filter-button">{{ .strings.filters }}</span>
<div class="dropdown-display"> <div class="dropdown-display">
<div class="card ~neutral @low mt-2" id="activity-filter-list"> <div class="card ~neutral @low mt-2" id="accounts-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p> <p class="supra pb-2">{{ .strings.filters }}</p>
</div> </div>
</div> </div>
</div> </div>
<input type="search" class="field ~neutral @low input search ml-2 mr-2" id="activity-search" placeholder="{{ .strings.search }}"> <input type="search" class="field ~neutral @low input search ml-2 mr-2" id="accounts-search" placeholder="{{ .strings.search }}">
<span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none activity-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span> <span class="button ~neutral @low center ml-[-2.64rem] rounded-s-none accounts-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
</div> </div>
<div class="supra py-1 sm hidden" id="activity-search-options-header">{{ .strings.searchOptions }}</div> <div class="supra py-1 sm hidden" id="accounts-search-options-header">{{ .strings.searchOptions }}</div>
<div class="row -mx-2 mb-2"> <div class="row -mx-2 mb-2">
<button type="button" class="button ~neutral @low center mx-2 hidden"><span id="activity-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button> <button type="button" class="button ~neutral @low center mx-2 hidden"><span id="accounts-sort-by-field"></span> <i class="ri-close-line ml-2 text-2xl"></i></button>
<span id="activity-filter-area"></span> <span id="accounts-filter-area"></span>
</div>
<div class="supra py-1 sm">{{ .strings.actions }}</div>
<div class="row -mx-2">
<span class="col button ~neutral @low center max-w-[20%]" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<div id="accounts-announce-dropdown" class="col dropdown pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~info @low center" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="supra sm">{{ .strings.templates }}</span>
<div id="accounts-announce-templates"></div>
</div>
</div>
</div>
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
{{ if .referralsEnabled }}
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</span>
{{ end }}
<span class="col button ~warning @low center max-w-[20%]" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<div id="accounts-disable-enable-dropdown" class="col dropdown manual pb-0i max-w-[20%]" tabindex="0">
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
<div class="dropdown-display">
<div class="card ~neutral @low">
<span class="button ~urge full-width accounts-announce-template-button" id="accounts-enable-expiry">{{ .strings.setExpiry }}</span>
</div>
</div>
</div>
<span class="col button ~info @low center unfocused max-w-[20%]" id="accounts-send-pwr">{{ .strings.sendPWR }}</span>
<span class="col button ~critical @low center max-w-[20%]" id="accounts-delete-user">{{ .quantityStrings.deleteUser.Singular }}</span>
</div> </div>
<div class="flex flex-col md:flex-row gap-3"> <div class="flex flex-col md:flex-row gap-3">
<div class="card @low dark:~d_neutral col max-w-[20%]"> <div class="card @low dark:~d_neutral col max-w-[20%]">
@ -750,14 +778,19 @@
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span> <span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-about"><span class="flex">{{ .strings.aboutProgram }} <i class="ri-information-line ml-2"></i></span></span>
<span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span> <span class="button ~neutral @low settings-section-button justify-between mb-2" id="setting-profiles"><span class="flex">{{ .strings.userProfiles }} <i class="ri-user-line ml-2"></i></span></span>
</div> </div>
<div class="card ~neutral @low col"> <div class="card ~neutral @low col overflow" id="activity-card-list">
<div id="activity-card-list"></div> <div class="card ~urge @low">
<div class="unfocused h-[100%] my-3" id="activity-not-found"> <div class="flex justify-between mb-2">
<div class="flex flex-col h-[100%] justify-center items-center"> <span class="heading text-2xl activity-title">Account Created: <a href="/fixme" class="activity-url">"hrfee"</a></span>
<span class="text-2xl font-medium italic mb-3">{{ .strings.noResultsFound }}</span> <span class="text-sm font-medium activity-time">26/10/23 14:32</span>
<button class="button ~neutral @low activity-search-clear"> </div>
<span class="mr-2">{{ .strings.clearSearch }}</span><i class="ri-close-line"></i> <div class="flex justify-between">
</button> <div>
<span class="content supra mr-2 activity-source-type">From Invite</span><a href="fixme" class="activity-source">BdBmpGDzuJhHSsboAsYgrE</a>
</div>
<div>
<span class="content supra mr-2">Referrer</span><a href="fixme" class="activity-referrer">username</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -134,8 +134,8 @@
"builtBy": "Built By", "builtBy": "Built By",
"loginNotAdmin": "Not an Admin?", "loginNotAdmin": "Not an Admin?",
"referrer": "Referrer", "referrer": "Referrer",
"accountLinked": "{contactMethod} linked: {user}", "accountLinked": "{user}: {contactMethod} linked",
"accountUnlinked": "{contactMethod} removed: {user}", "accountUnlinked": "{user}: {contactMethod} removed",
"accountResetPassword": "{user} reset their password", "accountResetPassword": "{user} reset their password",
"accountChangedPassword": "{user} changed their password", "accountChangedPassword": "{user} changed their password",
"accountCreated": "Account created: {user}", "accountCreated": "Account created: {user}",
@ -146,27 +146,7 @@
"userDeleted": "User was deleted.", "userDeleted": "User was deleted.",
"userDisabled": "User was disabled", "userDisabled": "User was disabled",
"inviteCreated": "Invite created: {invite}", "inviteCreated": "Invite created: {invite}",
"inviteDeleted": "Invite deleted: {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. <hr class=\"sep\"> \"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"
}, },
"notifications": { "notifications": {
"changedEmailAddress": "Changed email address of {n}.", "changedEmailAddress": "Changed email address of {n}.",
@ -182,7 +162,6 @@
"telegramVerified": "Telegram account verified.", "telegramVerified": "Telegram account verified.",
"accountConnected": "Account connected.", "accountConnected": "Account connected.",
"referralsEnabled": "Referrals enabled.", "referralsEnabled": "Referrals enabled.",
"activityDeleted": "Activity Deleted.",
"errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.", "errorSettingsAppliedNoHomescreenLayout": "Settings were applied, but applying homescreen layout may have failed.",
"errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.", "errorHomescreenAppliedNoSettings": "Homescreen layout was applied, but applying settings may have failed.",
"errorSettingsFailed": "Application failed.", "errorSettingsFailed": "Application failed.",

View File

@ -432,16 +432,14 @@ type EnableDisableReferralDTO struct {
} }
type ActivityDTO struct { type ActivityDTO struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Username string `json:"username"` SourceType string `json:"source_type"`
SourceType string `json:"source_type"` Source string `json:"source"`
Source string `json:"source"` InviteCode string `json:"invite_code"`
SourceUsername string `json:"source_username"` Value string `json:"value"`
InviteCode string `json:"invite_code"` Time int64 `json:"time"`
Value string `json:"value"`
Time int64 `json:"time"`
} }
type GetActivitiesDTO struct { type GetActivitiesDTO struct {

View File

@ -232,8 +232,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile) api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
} }
api.POST(p+"/activity", app.GetActivities) api.GET(p+"/activity", app.GetActivities)
api.DELETE(p+"/activity/:id", app.DeleteActivity)
if userPageEnabled { if userPageEnabled {
user.GET("/details", app.MyDetails) user.GET("/details", app.MyDetails)

View File

@ -52,8 +52,8 @@ type Activity struct {
UserID string // ID of target user. For account creation, this will be the newly created account UserID string // ID of target user. For account creation, this will be the newly created account
SourceType ActivitySource SourceType ActivitySource
Source string Source string
InviteCode string // Set for ActivityCreation, create/deleteInvite InviteCode string // Only set for ActivityCreation
Value string // Used for ActivityContactLinked where it's "email/discord/telegram/matrix", Create/DeleteInvite, where it's the label, and Creation/Deletion, where it's the Username. Value string // Used for ActivityContactLinked, "email/discord/telegram/matrix", and Create/DeleteInvite, where it's the label.
Time time.Time Time time.Time
} }

View File

@ -3,7 +3,6 @@ import { templateEmail } from "../modules/settings.js";
import { Marked } from "@ts-stack/markdown"; import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js"; import { stripMarkdown } from "../modules/stripmd.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
const dateParser = require("any-date-parser"); const dateParser = require("any-date-parser");
interface User { interface User {
@ -40,7 +39,7 @@ interface announcementTemplate {
var addDiscord: (passData: string) => void; var addDiscord: (passData: string) => void;
class user implements User, SearchableItem { class user implements User {
private _id = ""; private _id = "";
private _row: HTMLTableRowElement; private _row: HTMLTableRowElement;
private _check: HTMLInputElement; private _check: HTMLInputElement;
@ -270,7 +269,7 @@ class user implements User, SearchableItem {
<span class="chip btn @low"><i class="ri-link" alt="${window.lang.strings("add")}"></i></span> <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"> <input type="text" class="input ~neutral @low stealth-input unfocused" placeholder="@user:riot.im">
</div> </div>
`; `;
(this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix; (this._matrix.querySelector("span") as HTMLSpanElement).onclick = this._addMatrix;
} else { } else {
this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused"); this._notifyDropdown.querySelector(".accounts-area-matrix").classList.remove("unfocused");
@ -781,13 +780,13 @@ export class accountsList {
private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement; private _userSelect = document.getElementById("modify-user-users") as HTMLSelectElement;
private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement; private _referralsProfileSelect = document.getElementById("enable-referrals-user-profiles") as HTMLSelectElement;
private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement; private _referralsInviteSelect = document.getElementById("enable-referrals-user-invites") as HTMLSelectElement;
private _searchBox = document.getElementById("accounts-search") as HTMLInputElement; private _search = document.getElementById("accounts-search") as HTMLInputElement;
private _search: Search;
private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement; private _selectAll = document.getElementById("accounts-select-all") as HTMLInputElement;
private _users: { [id: string]: user }; private _users: { [id: string]: user };
private _ordering: string[] = []; private _ordering: string[] = [];
private _checkCount: number = 0; private _checkCount: number = 0;
private _inSearch = false;
// Whether the enable/disable button should enable or not. // Whether the enable/disable button should enable or not.
private _shouldEnable = false; private _shouldEnable = false;
@ -837,7 +836,7 @@ export class accountsList {
} }
} }
private _queries: { [field: string]: QueryType } = { private _queries: { [field: string]: { name: string, getter: string, bool: boolean, string: boolean, date: boolean, dependsOnTableHeader?: string, show?: boolean }} = {
"id": { "id": {
// We don't use a translation here to circumvent the name substitution feature. // We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID", name: "Jellyfin/Emby ID",
@ -888,7 +887,7 @@ export class accountsList {
bool: true, bool: true,
string: false, string: false,
date: false, date: false,
dependsOnElement: ".accounts-header-access-jfa" dependsOnTableHeader: "accounts-header-access-jfa"
}, },
"email": { "email": {
name: window.lang.strings("emailAddress"), name: window.lang.strings("emailAddress"),
@ -896,7 +895,7 @@ export class accountsList {
bool: true, bool: true,
string: true, string: true,
date: false, date: false,
dependsOnElement: ".accounts-header-email" dependsOnTableHeader: "accounts-header-email"
}, },
"telegram": { "telegram": {
name: "Telegram", name: "Telegram",
@ -904,7 +903,7 @@ export class accountsList {
bool: true, bool: true,
string: true, string: true,
date: false, date: false,
dependsOnElement: ".accounts-header-telegram" dependsOnTableHeader: "accounts-header-telegram"
}, },
"matrix": { "matrix": {
name: "Matrix", name: "Matrix",
@ -912,7 +911,7 @@ export class accountsList {
bool: true, bool: true,
string: true, string: true,
date: false, date: false,
dependsOnElement: ".accounts-header-matrix" dependsOnTableHeader: "accounts-header-matrix"
}, },
"discord": { "discord": {
name: "Discord", name: "Discord",
@ -920,7 +919,7 @@ export class accountsList {
bool: true, bool: true,
string: true, string: true,
date: false, date: false,
dependsOnElement: ".accounts-header-discord" dependsOnTableHeader: "accounts-header-discord"
}, },
"expiry": { "expiry": {
name: window.lang.strings("expiry"), name: window.lang.strings("expiry"),
@ -928,7 +927,7 @@ export class accountsList {
bool: true, bool: true,
string: false, string: false,
date: true, date: true,
dependsOnElement: ".accounts-header-expiry" dependsOnTableHeader: "accounts-header-expiry"
}, },
"last-active": { "last-active": {
name: window.lang.strings("lastActiveTime"), name: window.lang.strings("lastActiveTime"),
@ -943,12 +942,229 @@ export class accountsList {
bool: true, bool: true,
string: false, string: false,
date: false, date: false,
dependsOnElement: ".accounts-header-referrals" dependsOnTableHeader: "accounts-header-referrals"
} }
} }
private _notFoundPanel: HTMLElement = document.getElementById("accounts-not-found"); 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; } get selectAll(): boolean { return this._selectAll.checked; }
set selectAll(state: boolean) { set selectAll(state: boolean) {
let count = 0; let count = 0;
@ -1798,23 +2014,34 @@ export class accountsList {
this._deleteNotify.checked = false; this._deleteNotify.checked = false;
}*/ }*/
let conf: SearchConfiguration = { const onchange = () => {
filterArea: this._filterArea, const query = this._search.value;
sortingByButton: this._sortingByButton, if (!query) {
searchOptionsHeader: this._searchOptionsHeader, // this.setVisibility(this._ordering, true);
notFoundPanel: this._notFoundPanel, this._inSearch = false;
filterList: document.getElementById("accounts-filter-list"), } else {
search: this._searchBox, this._inSearch = true;
queries: this._queries, // this.setVisibility(this.search(query), true);
setVisibility: this.setVisibility, }
clearSearchButtonSelector: ".accounts-search-clear", const results = this.search(query);
onSearchCallback: () => { this.setVisibility(results, true);
this._checkCheckCount(); this._checkCheckCount();
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
} }
}; };
this._search = new Search(conf); this._search.oninput = onchange;
this._search.items = this._users;
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._announceTextarea.onkeyup = this.loadPreview; this._announceTextarea.onkeyup = this.loadPreview;
addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => { addDiscord = newDiscordSearch(window.lang.strings("linkDiscord"), window.lang.strings("searchDiscordUser"), window.lang.strings("add"), (user: DiscordUser, id: string) => {
@ -1861,16 +2088,15 @@ export class accountsList {
document.addEventListener("header-click", (event: CustomEvent) => { document.addEventListener("header-click", (event: CustomEvent) => {
this._ordering = this._columns[event.detail].sort(this._users); this._ordering = this._columns[event.detail].sort(this._users);
this._search.ordering = this._ordering;
this._activeSortColumn = event.detail; this._activeSortColumn = event.detail;
this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent; this._sortingByButton.innerHTML = this._columns[event.detail].buttonContent;
this._sortingByButton.parentElement.classList.remove("hidden"); this._sortingByButton.parentElement.classList.remove("hidden");
// console.log("ordering by", event.detail, ": ", this._ordering); // console.log("ordering by", event.detail, ": ", this._ordering);
if (!(this._search.inSearch)) { if (!(this._inSearch)) {
this.setVisibility(this._ordering, true); this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused");
} else { } else {
const results = this._search.search(this._searchBox.value); const results = this.search(this._search.value);
this.setVisibility(results, true); this.setVisibility(results, true);
if (results.length == 0) { if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused"); this._notFoundPanel.classList.remove("unfocused");
@ -1884,7 +2110,84 @@ export class accountsList {
defaultSort(); defaultSort();
this.showHideSearchOptionsHeader(); this.showHideSearchOptionsHeader();
this._search.generateFilterList(); const filterList = document.getElementById("accounts-filter-list");
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);
}
} }
reload = () => { reload = () => {
@ -1907,12 +2210,11 @@ export class accountsList {
} }
// console.log("reload, so sorting by", this._activeSortColumn); // console.log("reload, so sorting by", this._activeSortColumn);
this._ordering = this._columns[this._activeSortColumn].sort(this._users); this._ordering = this._columns[this._activeSortColumn].sort(this._users);
this._search.ordering = this._ordering; if (!(this._inSearch)) {
if (!(this._search.inSearch)) {
this.setVisibility(this._ordering, true); this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("unfocused"); this._notFoundPanel.classList.add("unfocused");
} else { } else {
const results = this._search.search(this._searchBox.value); const results = this.search(this._search.value);
if (results.length == 0) { if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused"); this._notFoundPanel.classList.remove("unfocused");
} else { } else {

View File

@ -1,17 +1,14 @@
import { _post, _delete, toDateString } from "../modules/common.js"; import { _get, toDateString } from "../modules/common.js";
import { Search, SearchConfiguration, QueryType, SearchableItem } from "../modules/search.js";
export interface activity { export interface activity {
id: string; id: string;
type: string; type: string;
user_id: string; user_id: string;
source_type: string; source_type: string;
source: string; source: string;
invite_code: string; invite_code: string;
value: string; value: string;
time: number; time: number;
username: string;
source_username: string;
} }
var activityTypeMoods = { var activityTypeMoods = {
@ -27,11 +24,9 @@ var activityTypeMoods = {
"deleteInvite": -1 "deleteInvite": -1
}; };
// var moodColours = ["~warning", "~neutral", "~urge"]; var moodColours = ["~warning", "~neutral", "~urge"];
export var activityReload = new CustomEvent("activity-reload"); export class Activity { // FIXME: Add "implements"
export class Activity implements activity, SearchableItem { // FIXME: Add "implements"
private _card: HTMLElement; private _card: HTMLElement;
private _title: HTMLElement; private _title: HTMLElement;
private _time: HTMLElement; private _time: HTMLElement;
@ -40,90 +35,27 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
private _source: HTMLElement; private _source: HTMLElement;
private _referrer: HTMLElement; private _referrer: HTMLElement;
private _expiryTypeBadge: HTMLElement; private _expiryTypeBadge: HTMLElement;
private _delete: HTMLElement;
private _act: activity; private _act: activity;
_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 `<a class="hover:underline" href="/accounts/user/${this._act.user_id}">${this._genUserText()}</a>`;
}
_genSrcUserLink = (): string => {
return `<a class="hover:underline" href="/accounts/user/${this._act.source}">${this._genSrcUserText()}</a>`;
}
private _renderInvText = (): string => { return `<span class="font-medium font-mono">${this.value || this.invite_code || "???"}</span>`; }
private _genInvLink = (): string => {
return `<a class="hover:underline" href="/accounts/invites/${this.invite_code}">${this._renderInvText()}</a>`;
}
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; } get type(): string { return this._act.type; }
set type(v: string) { set type(v: string) {
this._act.type = v; this._act.type = v;
let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative let mood = activityTypeMoods[v]; // 1 = positive, 0 = neutral, -1 = negative
for (let el of [this._card, this._delete]) {
el.classList.remove("~warning"); for (let i = 0; i < moodColours.length; i++) {
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]); if (i-1 == mood) this._card.classList.add(moodColours[i]);
else this._card.classList.remove(moodColours[i]); else this._card.classList.remove(moodColours[i]);
} */ }
if (this.type == "changePassword" || this.type == "resetPassword") { if (this.type == "changePassword" || this.type == "resetPassword") {
let innerHTML = ``; let innerHTML = ``;
if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword"); if (this.type == "changePassword") innerHTML = window.lang.strings("accountChangedPassword");
else innerHTML = window.lang.strings("accountResetPassword"); else innerHTML = window.lang.strings("accountResetPassword");
innerHTML = innerHTML.replace("{user}", this._genUserLink()); innerHTML = innerHTML.replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._title.innerHTML = innerHTML; this._title.innerHTML = innerHTML;
} else if (this.type == "contactLinked" || this.type == "contactUnlinked") { } else if (this.type == "contactLinked" || this.type == "contactUnlinked") {
let platform = this.value; let platform = this._act.type;
if (platform == "email") { if (platform == "email") {
platform = window.lang.strings("emailAddress"); platform = window.lang.strings("emailAddress");
} else { } else {
@ -132,46 +64,40 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
let innerHTML = ``; let innerHTML = ``;
if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked"); if (this.type == "contactLinked") innerHTML = window.lang.strings("accountLinked");
else innerHTML = window.lang.strings("accountUnlinked"); else innerHTML = window.lang.strings("accountUnlinked");
innerHTML = innerHTML.replace("{user}", this._genUserLink()).replace("{contactMethod}", platform); innerHTML = innerHTML.replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`).replace("{contactMethod}", platform);
this._title.innerHTML = innerHTML; this._title.innerHTML = innerHTML;
} else if (this.type == "creation") { } else if (this.type == "creation") {
this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", this._genUserLink()); this._title.innerHTML = window.lang.strings("accountCreated").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
if (this.source_type == "user") { if (this.source_type == "user") {
this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span>${this._genSrcUserLink()}`; this._referrer.innerHTML = `<span class="supra mr-2">${window.lang.strings("referrer")}</span><a href="/accounts/${this._source}">FIXME</a>`;
} else { } else {
this._referrer.textContent = ``; this._referrer.textContent = ``;
} }
} else if (this.type == "deletion") { } else if (this.type == "deletion") {
if (this.source_type == "daemon") { if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserText()); this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._expiryTypeBadge.classList.add("~critical"); this._expiryTypeBadge.classList.add("~critical");
this._expiryTypeBadge.classList.remove("~info"); this._expiryTypeBadge.classList.remove("~warning");
this._expiryTypeBadge.textContent = window.lang.strings("deleted"); this._expiryTypeBadge.textContent = window.lang.strings("deleted");
} else { } else {
this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", this._genUserText()); this._title.innerHTML = window.lang.strings("accountDeleted").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
} }
} else if (this.type == "enabled") { } else if (this.type == "enabled") {
this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", this._genUserLink()); this._title.innerHTML = window.lang.strings("accountReEnabled").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
} else if (this.type == "disabled") { } else if (this.type == "disabled") {
if (this.source_type == "daemon") { if (this.source_type == "daemon") {
this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", this._genUserLink()); this._title.innerHTML = window.lang.strings("accountExpired").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
this._expiryTypeBadge.classList.add("~info"); this._expiryTypeBadge.classList.add("~warning");
this._expiryTypeBadge.classList.remove("~critical"); this._expiryTypeBadge.classList.remove("~critical");
this._expiryTypeBadge.textContent = window.lang.strings("disabled"); this._expiryTypeBadge.textContent = window.lang.strings("disabled");
} else { } else {
this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", this._genUserLink()); this._title.innerHTML = window.lang.strings("accountDisabled").replace("{user}", `<a href="/accounts/user/${this._act.user_id}">FIXME</a>`);
} }
} else if (this.type == "createInvite") { } else if (this.type == "createInvite") {
this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", this._genInvLink()); this._title.innerHTML = window.lang.strings("inviteCreated").replace("{invite}", `<a href="/accounts/user/${this.invite_code}">${this.value || this.invite_code}</a>`);
} else if (this.type == "deleteInvite") { } 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()); this._title.innerHTML = window.lang.strings("inviteDeleted").replace("{invite}", this.value || this.invite_code);
} }
/*} else if (this.source_type == "admin") { /*} else if (this.source_type == "admin") {
@ -192,15 +118,6 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
get source_type(): string { return this._act.source_type; } get source_type(): string { return this._act.source_type; }
set source_type(v: string) { set source_type(v: string) {
this._act.source_type = v; 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; } get invite_code(): string { return this._act.invite_code; }
@ -213,61 +130,22 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
this._act.value = v; 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) { constructor(act: activity) {
this._card = document.createElement("div"); this._card = document.createElement("div");
this._card.classList.add("card", "@low", "my-2"); this._card.classList.add("card", "@low");
this._card.innerHTML = ` this._card.innerHTML = `
<div class="flex flex-col md:flex-row justify-between mb-2"> <div class="flex justify-between mb-2">
<span class="heading truncate flex-initial md:text-2xl text-xl activity-title"></span> <span class="heading text-2xl activity-title"></span><span class="activity-expiry-type badge"></span>
<div class="flex flex-col flex-none ml-0 md:ml-2"> <span class="text-sm font-medium activity-time" aria-label="${window.lang.strings("date")}"></span>
<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>
<div class="flex flex-col md:flex-row justify-between"> <div class="flex justify-between">
<div> <div>
<span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span> <span class="content supra mr-2 activity-source-type"></span><span class="activity-source"></span>
</div> </div>
<div> <div>
<span class="content activity-referrer"></span> <span class="content activity-referrer"></span>
</div> </div>
<div>
<button class="button @low hover:~critical rounded-full px-1 py-px activity-delete" aria-label="${window.lang.strings("delete")}"><i class="ri-close-line"></i></button>
</div>
</div> </div>
`; `;
@ -277,13 +155,6 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
this._source = this._card.querySelector(".activity-source"); this._source = this._card.querySelector(".activity-source");
this._referrer = this._card.querySelector(".activity-referrer"); this._referrer = this._card.querySelector(".activity-referrer");
this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type"); this._expiryTypeBadge = this._card.querySelector(".activity-expiry-type");
this._delete = this._card.querySelector(".activity-delete");
document.addEventListener("timefmt-change", () => {
this.time = this.time;
});
this._delete.addEventListener("click", this.delete);
this.update(act); this.update(act);
} }
@ -293,20 +164,10 @@ export class Activity implements activity, SearchableItem { // FIXME: Add "imple
this._act = act; this._act = act;
this.source_type = act.source_type; this.source_type = act.source_type;
this.invite_code = act.invite_code; this.invite_code = act.invite_code;
this.time = act.time;
this.source = act.source;
this.value = act.value; this.value = act.value;
this.type = act.type; 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; }; asElement = () => { return this._card; };
} }
@ -316,35 +177,9 @@ interface ActivitiesDTO {
export class activityList { export class activityList {
private _activityList: HTMLElement; 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 _search: Search;
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 = () => { reload = () => {
let send = { _get("/activity", null, (req: XMLHttpRequest) => {
"type": [],
"limit": 60,
"page": 0,
"ascending": false
}
_post("/activity", send, (req: XMLHttpRequest) => {
if (req.readyState != 4) return; if (req.readyState != 4) return;
if (req.status != 200) { if (req.status != 200) {
window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities")); window.notifications.customError("loadActivitiesError", window.lang.notif("errorLoadActivities"));
@ -352,165 +187,16 @@ export class activityList {
} }
let resp = req.response as ActivitiesDTO; let resp = req.response as ActivitiesDTO;
// FIXME: Don't destroy everything each reload! this._activityList.textContent = ``;
this._activities = {};
for (let act of resp.activities) {
this._activities[act.id] = new Activity(act);
this._activityList.appendChild(this._activities[act.id].asElement());
}
this._search.items = this._activities;
// FIXME: Actually implement sorting
this._ordering = Object.keys(this._activities);
this._search.ordering = this._ordering;
if (this._search.inSearch) { for (let act of resp.activities) {
const results = this._search.search(this._searchBox.value); const activity = new Activity(act);
this.setVisibility(results, true); this._activityList.appendChild(activity.asElement());
if (results.length == 0) {
this._notFoundPanel.classList.remove("unfocused");
} else {
this._notFoundPanel.classList.add("unfocused");
}
} else {
this.setVisibility(this._ordering, true);
this._notFoundPanel.classList.add("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
}
};
constructor() { constructor() {
this._activityList = document.getElementById("activity-card-list"); 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"),
onSearchCallback: () => {}
}
this._search = new Search(conf);
this._search.generateFilterList();
} }
} }

View File

@ -1,385 +0,0 @@
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;
filterList: HTMLElement;
clearSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
setVisibility: (items: string[], visible: boolean) => void;
onSearchCallback: () => 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 = () => {
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();
this.showHideSearchOptionsHeader();
if (results.length == 0) {
this._c.notFoundPanel.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
}
}
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();
});
}
}
}

View File

@ -61,10 +61,10 @@ func (app *appContext) checkUsers() {
return return
} }
mode := "disable" mode := "disable"
term := "Disabling" termPlural := "Disabling"
if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" { if app.config.Section("user_expiry").Key("behaviour").MustString("disable_user") == "delete_user" {
mode = "delete" mode = "delete"
term = "Deleting" termPlural = "Deleting"
} }
contact := false contact := false
if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) { if messagesEnabled && app.config.Section("user_expiry").Key("send_email").MustBool(true) {
@ -95,7 +95,7 @@ func (app *appContext) checkUsers() {
app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue continue
} }
app.info.Printf("%s expired user \"%s\"", term, user.Name) app.info.Printf("%s expired user \"%s\"", termPlural, user.Name)
// Record activity // Record activity
activity := Activity{ activity := Activity{
@ -107,7 +107,6 @@ func (app *appContext) checkUsers() {
if mode == "delete" { if mode == "delete" {
status, err = app.jf.DeleteUser(id) status, err = app.jf.DeleteUser(id)
activity.Type = ActivityDeletion activity.Type = ActivityDeletion
activity.Value = user.Name
} else if mode == "disable" { } else if mode == "disable" {
user.Policy.IsDisabled = true user.Policy.IsDisabled = true
// Admins can't be disabled // Admins can't be disabled