diff --git a/api-invites.go b/api-invites.go index e8b424c..0544b03 100644 --- a/api-invites.go +++ b/api-invites.go @@ -186,6 +186,100 @@ func (app *appContext) SendInvite(gc *gin.Context) { respondBool(200, true, gc) } +// @Summary Edit an existing invite. Not all fields are modifiable. +// @Produce json +// @Param EditableInviteDTO body EditableInviteDTO true "Email address or Discord username" +// @Success 200 {object} boolResponse +// @Failure 500 {object} stringResponse +// @Failure 400 {object} stringResponse +// @Router /invites/edit [patch] +// @Security Bearer +// @tags Invites +func (app *appContext) EditInvite(gc *gin.Context) { + var req EditableInviteDTO + gc.BindJSON(&req) + inv, ok := app.storage.GetInvitesKey(req.Code) + if !ok { + msg := fmt.Sprintf(lm.InvalidInviteCode, req.Code) + app.err.Println(msg) + respond(400, msg, gc) + return + } + changed := false + + if req.NotifyCreation != nil || req.NotifyExpiry != nil { + setNotify := map[string]bool{} + if req.NotifyExpiry != nil { + setNotify["notify-expiry"] = *req.NotifyExpiry + } + if req.NotifyCreation != nil { + setNotify["notify-creation"] = *req.NotifyCreation + } + ch, ok := app.SetNotify(&inv, setNotify, gc) + changed = changed || ch + if ch && !ok { + return + } + } + if req.Profile != nil { + ch, ok := app.SetProfile(&inv, *req.Profile, gc) + changed = changed || ch + if ch && !ok { + return + } + } + if req.Label != nil { + *req.Label = strings.TrimSpace(*req.Label) + changed = changed || (*req.Label != inv.Label) + inv.Label = *req.Label + } + if req.UserLabel != nil { + *req.UserLabel = strings.TrimSpace(*req.UserLabel) + changed = changed || (*req.UserLabel != inv.UserLabel) + inv.UserLabel = *req.UserLabel + } + if req.UserExpiry != nil { + changed = changed || (*req.UserExpiry != inv.UserExpiry) + inv.UserExpiry = *req.UserExpiry + if !inv.UserExpiry { + inv.UserMonths = 0 + inv.UserDays = 0 + inv.UserHours = 0 + inv.UserMinutes = 0 + } + } + if req.UserMonths != nil || req.UserDays != nil || req.UserHours != nil || req.UserMinutes != nil { + if inv.UserMonths == 0 && + inv.UserDays == 0 && + inv.UserHours == 0 && + inv.UserMinutes == 0 { + changed = changed || (inv.UserExpiry != false) + inv.UserExpiry = false + } + if req.UserMonths != nil { + changed = changed || (*req.UserMonths != inv.UserMonths) + inv.UserMonths = *req.UserMonths + } + if req.UserDays != nil { + changed = changed || (*req.UserDays != inv.UserDays) + inv.UserDays = *req.UserDays + } + if req.UserHours != nil { + changed = changed || (*req.UserHours != inv.UserHours) + inv.UserHours = *req.UserHours + } + if req.UserMinutes != nil { + changed = changed || (*req.UserMinutes != inv.UserMinutes) + inv.UserMinutes = *req.UserMinutes + } + } + + if changed { + app.storage.SetInvitesKey(inv.Code, inv) + } + respondBool(200, true, gc) +} + // sendInvite attempts to send an invite to the given email address or discord username. func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) { if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) { @@ -369,22 +463,24 @@ func (app *appContext) GetInvites(gc *gin.Context) { // years, months, days, hours, minutes, _ := timeDiff(inv.ValidTill, currentTime) // months += years * 12 invite := inviteDTO{ - Code: inv.Code, + EditableInviteDTO: EditableInviteDTO{ + Code: inv.Code, + Label: &inv.Label, + UserLabel: &inv.UserLabel, + Profile: &inv.Profile, + UserExpiry: &inv.UserExpiry, + UserMonths: &inv.UserMonths, + UserDays: &inv.UserDays, + UserHours: &inv.UserHours, + UserMinutes: &inv.UserMinutes, + }, ValidTill: inv.ValidTill.Unix(), // Months: months, // Days: days, // Hours: hours, // Minutes: minutes, - UserExpiry: inv.UserExpiry, - UserMonths: inv.UserMonths, - UserDays: inv.UserDays, - UserHours: inv.UserHours, - UserMinutes: inv.UserMinutes, - Created: inv.Created.Unix(), - Profile: inv.Profile, - NoLimit: inv.NoLimit, - Label: inv.Label, - UserLabel: inv.UserLabel, + Created: inv.Created.Unix(), + NoLimit: inv.NoLimit, } if len(inv.UsedBy) != 0 { invite.UsedBy = map[string]int64{} @@ -420,10 +516,12 @@ func (app *appContext) GetInvites(gc *gin.Context) { } if _, ok := inv.Notify[addressOrID]; ok { if _, ok = inv.Notify[addressOrID]["notify-expiry"]; ok { - invite.NotifyExpiry = inv.Notify[addressOrID]["notify-expiry"] + notifyExpiry := inv.Notify[addressOrID]["notify-expiry"] + invite.NotifyExpiry = ¬ifyExpiry } if _, ok = inv.Notify[addressOrID]["notify-creation"]; ok { - invite.NotifyCreation = inv.Notify[addressOrID]["notify-creation"] + notifyCreation := inv.Notify[addressOrID]["notify-creation"] + invite.NotifyCreation = ¬ifyCreation } } } @@ -435,82 +533,54 @@ func (app *appContext) GetInvites(gc *gin.Context) { gc.JSON(200, resp) } -// @Summary Set profile for an invite -// @Produce json -// @Param inviteProfileDTO body inviteProfileDTO true "Invite profile object" -// @Success 200 {object} boolResponse -// @Failure 500 {object} stringResponse -// @Router /invites/profile [post] -// @Security Bearer -// @tags Invites -func (app *appContext) SetProfile(gc *gin.Context) { - var req inviteProfileDTO - gc.BindJSON(&req) +func (app *appContext) SetProfile(inv *Invite, name string, gc *gin.Context) (changed, ok bool) { + changed = false + ok = false // "" means "Don't apply profile" - if _, ok := app.storage.GetProfileKey(req.Profile); !ok && req.Profile != "" { - app.err.Printf(lm.FailedGetProfile, req.Profile) + if _, profileExists := app.storage.GetProfileKey(name); !profileExists && name != "" { + app.err.Printf(lm.FailedGetProfile, name) respond(500, "Profile not found", gc) return } - inv, _ := app.storage.GetInvitesKey(req.Invite) - inv.Profile = req.Profile - app.storage.SetInvitesKey(req.Invite, inv) - respondBool(200, true, gc) + changed = name != inv.Profile + inv.Profile = name + ok = true + return } -// @Summary Set notification preferences for an invite. -// @Produce json -// @Param setNotifyDTO body setNotifyDTO true "Map of invite codes to notification settings objects" -// @Success 200 -// @Failure 400 {object} stringResponse -// @Failure 500 {object} stringResponse -// @Router /invites/notify [post] -// @Security Bearer -// @tags Other -func (app *appContext) SetNotify(gc *gin.Context) { - var req map[string]map[string]bool - gc.BindJSON(&req) - changed := false - for code, settings := range req { - invite, ok := app.storage.GetInvitesKey(code) - if !ok { - msg := fmt.Sprintf(lm.InvalidInviteCode, code) - app.err.Println(msg) - respond(400, msg, gc) +func (app *appContext) SetNotify(inv *Invite, settings map[string]bool, gc *gin.Context) (changed, ok bool) { + changed = false + ok = false + var address string + jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false) + if jellyfinLogin { + var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != "" + if !addressAvailable { + app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId")) + respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc) return } - var address string - jellyfinLogin := app.config.Section("ui").Key("jellyfin_login").MustBool(false) - if jellyfinLogin { - var addressAvailable bool = app.getAddressOrName(gc.GetString("jfId")) != "" - if !addressAvailable { - app.err.Printf(lm.FailedGetContactMethod, gc.GetString("jfId")) - respond(500, fmt.Sprintf(lm.FailedGetContactMethod, "admin"), gc) - return - } - address = gc.GetString("jfId") - } else { - address = app.config.Section("ui").Key("email").String() - } - if invite.Notify == nil { - invite.Notify = map[string]map[string]bool{} - } - if _, ok := invite.Notify[address]; !ok { - invite.Notify[address] = map[string]bool{} - } /*else { - if _, ok := invite.Notify[address]["notify-expiry"]; !ok { - */ - for _, notifyType := range []string{"notify-expiry", "notify-creation"} { - if _, ok := settings[notifyType]; ok && invite.Notify[address][notifyType] != settings[notifyType] { - invite.Notify[address][notifyType] = settings[notifyType] - app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address) - changed = true - } - } - if changed { - app.storage.SetInvitesKey(code, invite) + address = gc.GetString("jfId") + } else { + address = app.config.Section("ui").Key("email").String() + } + if inv.Notify == nil { + inv.Notify = map[string]map[string]bool{} + } + if _, ok := inv.Notify[address]; !ok { + inv.Notify[address] = map[string]bool{} + } /*else { + if _, ok := invite.Notify[address]["notify-expiry"]; !ok { + */ + for _, notifyType := range []string{"notify-expiry", "notify-creation"} { + if _, ok := settings[notifyType]; ok && inv.Notify[address][notifyType] != settings[notifyType] { + inv.Notify[address][notifyType] = settings[notifyType] + app.debug.Printf(lm.SetAdminNotify, notifyType, settings[notifyType], address) + changed = true } } + ok = true + return } // @Summary Delete an invite. diff --git a/models.go b/models.go index 55eb2c9..215dbb2 100644 --- a/models.go +++ b/models.go @@ -79,11 +79,6 @@ type sendInviteDTO struct { SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name } -type inviteProfileDTO struct { - Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to - Profile string `json:"profile" example:"DefaultProfile"` // Profile to use -} - type profileDTO struct { Admin bool `json:"admin" example:"false"` // Whether profile has admin rights or not LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to @@ -115,25 +110,29 @@ type newProfileDTO struct { } type inviteDTO struct { - Code string `json:"code" example:"sajdlj23423j23"` // Invite code + EditableInviteDTO ValidTill int64 `json:"valid_till" example:"1617737207510"` // Unix timestamp of expiry - UserExpiry bool `json:"user_expiry"` // Whether or not user expiry is enabled - UserMonths int `json:"user_months,omitempty" example:"1"` // Number of months till user expiry - UserDays int `json:"user_days,omitempty" example:"1"` // Number of days till user expiry - UserHours int `json:"user_hours,omitempty" example:"2"` // Number of hours till user expiry - UserMinutes int `json:"user_minutes,omitempty" example:"3"` // Number of minutes till user expiry Created int64 `json:"created" example:"1617737207510"` // Date of creation - Profile string `json:"profile" example:"DefaultProfile"` // Profile used on this invite UsedBy map[string]int64 `json:"used_by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time NoLimit bool `json:"no_limit"` // If true, invite can be used any number of times RemainingUses int `json:"remaining_uses,omitempty"` // Remaining number of uses (if applicable) SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable) SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed. +} - NotifyExpiry bool `json:"notify_expiry,omitempty"` // Whether to notify the requesting user of expiry or not - NotifyCreation bool `json:"notify_creation,omitempty"` // Whether to notify the requesting user of account creation or not - Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite - UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite. +type EditableInviteDTO struct { + Code string `json:"code" example:"sajdlj23423j23"` // Invite code + + NotifyExpiry *bool `json:"notify_expiry,omitempty"` // Whether to notify the requesting user of expiry or not + NotifyCreation *bool `json:"notify_creation,omitempty"` // Whether to notify the requesting user of account creation or not + Label *string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite + UserLabel *string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite. + Profile *string `json:"profile" example:"DefaultProfile"` // Profile used on this invite + UserExpiry *bool `json:"user_expiry"` // Whether or not user expiry is enabled + UserMonths *int `json:"user_months,omitempty" example:"1"` // Number of months till user expiry + UserDays *int `json:"user_days,omitempty" example:"1"` // Number of days till user expiry + UserHours *int `json:"user_hours,omitempty" example:"2"` // Number of hours till user expiry + UserMinutes *int `json:"user_minutes,omitempty" example:"3"` // Number of minutes till user expiry } type getInvitesDTO struct { diff --git a/router.go b/router.go index 792f370..6095021 100644 --- a/router.go +++ b/router.go @@ -212,8 +212,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.GET(p+"/invites/count", app.GetInviteCount) api.GET(p+"/invites/count/used", app.GetInviteUsedCount) api.DELETE(p+"/invites", app.DeleteInvite) - api.POST(p+"/invites/profile", app.SetProfile) api.POST(p+"/invites/send", app.SendInvite) + api.PATCH(p+"/invites/edit", app.EditInvite) api.GET(p+"/profiles", app.GetProfiles) api.GET(p+"/profiles/names", app.GetProfileNames) api.GET(p+"/profiles/raw/:name", app.GetRawProfile) @@ -221,7 +221,6 @@ func (app *appContext) loadRoutes(router *gin.Engine) { api.POST(p+"/profiles/default", app.SetDefaultProfile) api.POST(p+"/profiles", app.CreateProfile) api.DELETE(p+"/profiles", app.DeleteProfile) - api.POST(p+"/invites/notify", app.SetNotify) api.POST(p+"/users/emails", app.ModifyEmails) api.POST(p+"/users/labels", app.ModifyLabels) api.POST(p+"/users/accounts-admin", app.SetAccountsAdmin) diff --git a/ts/modules/common.ts b/ts/modules/common.ts index 56fbdb1..4de8294 100644 --- a/ts/modules/common.ts +++ b/ts/modules/common.ts @@ -142,6 +142,8 @@ export const _post = (url: string, data: Object, onreadystatechange: (req: XMLHt export const _put = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => _req("PUT", url, data, onreadystatechange, response, statusHandler, noConnectionError); +export const _patch = (url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, response?: boolean, statusHandler?: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void => _req("PATCH", url, data, onreadystatechange, response, statusHandler, noConnectionError); + export function _delete(url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void, noConnectionError: boolean = false): void { let req = new XMLHttpRequest(); if (window.pages) { url = window.pages.Base + url; } diff --git a/ts/modules/invites.ts b/ts/modules/invites.ts index 1a0f75c..7a96dca 100644 --- a/ts/modules/invites.ts +++ b/ts/modules/invites.ts @@ -1,6 +1,7 @@ -import { _get, _post, _delete, toClipboard, toggleLoader, toDateString, SetupCopyButton, addLoader, removeLoader, DateCountdown } from "../modules/common.js"; +import { _get, _post, _delete, _patch, toClipboard, toggleLoader, toDateString, SetupCopyButton, addLoader, removeLoader, DateCountdown } from "../modules/common.js"; import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js"; import { reloadProfileNames } from "../modules/profiles.js"; +import { HiddenInputField } from "./ui.js"; declare var window: GlobalWindow; @@ -14,16 +15,18 @@ export const generateCodeLink = (code: string): string => { class DOMInvite implements Invite { updateNotify = (checkbox: HTMLInputElement) => { - let state: { [code: string]: { [type: string]: boolean } } = {}; + let state = { + code: this.code, + notify_expiry: this.notify_expiry, + notify_creation: this.notify_creation + }; let revertChanges: () => void; if (checkbox.classList.contains("inv-notify-expiry")) { revertChanges = () => { this.notify_expiry = !this.notify_expiry }; - state[this.code] = { "notify-expiry": this.notify_expiry }; } else { revertChanges = () => { this.notify_creation = !this.notify_creation }; - state[this.code] = { "notify-creation": this.notify_creation }; } - _post("/invites/notify", state, (req: XMLHttpRequest) => { + _patch("/invites/edit", state, (req: XMLHttpRequest) => { if (req.readyState == 4 && !(req.status == 200 || req.status == 204)) { revertChanges(); } @@ -38,18 +41,6 @@ class DOMInvite implements Invite { } }) - private _label: string = ""; - get label(): string { return this._label; } - set label(label: string) { - this._label = label; - const linkEl = this._codeArea.querySelector("a") as HTMLAnchorElement; - if (label == "") { - linkEl.textContent = this.code.replace(/-/g, '-'); - } else { - linkEl.textContent = label; - } - } - private _userLabel: string = ""; get user_label(): string { return this._userLabel; } set user_label(label: string) { @@ -67,16 +58,26 @@ class DOMInvite implements Invite { } } + private _label: string = ""; + get label(): string { return this._label; } + set label(label: string) { + this._label = label; + if (label == "") { + this.code = this.code; + } else { + this._labelEditor.value = label; + } + } + private _code: string = "None"; get code(): string { return this._code; } set code(code: string) { this._code = code; this._codeLink = generateCodeLink(code); - const linkEl = this._codeArea.querySelector("a") as HTMLAnchorElement; if (this.label == "") { - linkEl.textContent = code.replace(/-/g, '-'); + this._labelEditor.value = code.replace(/-/g, '-'); } - linkEl.href = this._codeLink; + this._linkEl.href = this._codeLink; } private _codeLink: string; @@ -311,8 +312,9 @@ class DOMInvite implements Invite { const select = this._left.querySelector("select") as HTMLSelectElement; const previous = this.profile; let profile = select.value; - if (profile == "noProfile") { profile = ""; } - _post("/invites/profile", { "invite": this.code, "profile": profile }, (req: XMLHttpRequest) => { + let state = {code: this.code}; + if (profile != "noProfile") { state["profile"] = profile }; + _patch("/invites/edit", state, (req: XMLHttpRequest) => { if (req.readyState == 4) { if (!(req.status == 200 || req.status == 204)) { select.value = previous || "noProfile"; @@ -323,6 +325,22 @@ class DOMInvite implements Invite { }); } + private _setLabel = () => { + const newLabel = this._labelEditor.value.trim(); + const old = this.label; + this.label = newLabel; + let state = { + code: this.code, + label: newLabel + }; + _patch("/invites/edit", state, (req: XMLHttpRequest) => { + if (req.readyState != 4) return; + if (req.status != 200 && req.status != 204) { + this.label = old; + } + }); + } + private _container: HTMLDivElement; private _header: HTMLDivElement; @@ -335,6 +353,10 @@ class DOMInvite implements Invite { private _right: HTMLDivElement; private _userTable: HTMLDivElement; + private _linkContainer: HTMLElement; + private _linkEl: HTMLAnchorElement; + private _labelEditor: HiddenInputField; + private _detailsToggle: HTMLInputElement; // whether the details card is expanded. @@ -413,11 +435,22 @@ class DOMInvite implements Invite { this._codeArea.classList.add("flex", "flex-row", "flex-wrap", "justify-between", "w-full", "items-center", "gap-2", "truncate"); this._codeArea.innerHTML = `
- +
${window.lang.var("strings", "inviteExpiresInTime", "")} `; + + this._linkContainer = this._codeArea.getElementsByClassName("invite-link-container")[0] as HTMLElement; + this._labelEditor = new HiddenInputField({ + container: this._linkContainer, + buttonOnLeft: false, + customContainerHTML: ``, + clickAwayShouldSave: true, + onSet: this._setLabel + }); + this._linkEl = this._linkContainer.getElementsByClassName("invite-link")[0] as HTMLAnchorElement; + const copyButton = this._codeArea.getElementsByClassName("invite-copy-button")[0] as HTMLButtonElement; SetupCopyButton(copyButton, this._codeLink); diff --git a/ts/modules/ui.ts b/ts/modules/ui.ts index c92031e..006047d 100644 --- a/ts/modules/ui.ts +++ b/ts/modules/ui.ts @@ -44,6 +44,14 @@ export class HiddenInputField { this._toggle.onclick = () => { this.editing = !this.editing; }; + this._input.addEventListener("keypress", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + this._toggle.click(); + } + }) + + this.setEditing(false, true); } @@ -66,6 +74,7 @@ export class HiddenInputField { this._toggle.classList.add(HiddenInputField.saveClass); this._toggle.classList.remove(HiddenInputField.editClass); this._input.classList.remove("hidden"); + this._input.focus(); this._content.classList.add("hidden"); } else { document.removeEventListener("click", this.outerClickListener);