mirror of
https://github.com/hrfee/jfa-go.git
synced 2024-11-09 20:00:12 +00:00
Compare commits
3 Commits
c2f835c897
...
729552a827
Author | SHA1 | Date | |
---|---|---|---|
729552a827 | |||
cdc8f9af4b | |||
9e5034ebab |
@ -1,9 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/lithammer/shortuuid/v3"
|
||||||
"github.com/timshannon/badgerhold/v4"
|
"github.com/timshannon/badgerhold/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,13 +21,23 @@ func (app *appContext) GetProfiles(gc *gin.Context) {
|
|||||||
DefaultProfile: app.storage.GetDefaultProfile().Name,
|
DefaultProfile: app.storage.GetDefaultProfile().Name,
|
||||||
Profiles: map[string]profileDTO{},
|
Profiles: map[string]profileDTO{},
|
||||||
}
|
}
|
||||||
|
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
|
||||||
|
baseInv := Invite{}
|
||||||
for _, p := range app.storage.GetProfiles() {
|
for _, p := range app.storage.GetProfiles() {
|
||||||
out.Profiles[p.Name] = profileDTO{
|
pdto := profileDTO{
|
||||||
Admin: p.Admin,
|
Admin: p.Admin,
|
||||||
LibraryAccess: p.LibraryAccess,
|
LibraryAccess: p.LibraryAccess,
|
||||||
FromUser: p.FromUser,
|
FromUser: p.FromUser,
|
||||||
Ombi: p.Ombi != nil,
|
Ombi: p.Ombi != nil,
|
||||||
|
ReferralsEnabled: false,
|
||||||
}
|
}
|
||||||
|
if referralsEnabled {
|
||||||
|
err := app.storage.db.Get(p.ReferralTemplateKey, &baseInv)
|
||||||
|
if p.ReferralTemplateKey != "" && err == nil {
|
||||||
|
pdto.ReferralsEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Profiles[p.Name] = pdto
|
||||||
}
|
}
|
||||||
gc.JSON(200, out)
|
gc.JSON(200, out)
|
||||||
}
|
}
|
||||||
@ -111,3 +123,76 @@ func (app *appContext) DeleteProfile(gc *gin.Context) {
|
|||||||
app.storage.DeleteProfileKey(name)
|
app.storage.DeleteProfileKey(name)
|
||||||
respondBool(200, true, gc)
|
respondBool(200, true, gc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Summary Enable referrals for a profile, sourced from the given invite by its code.
|
||||||
|
// @Produce json
|
||||||
|
// @Param profile path string true "name of profile to enable referrals for."
|
||||||
|
// @Param invite path string true "invite code to create referral template from."
|
||||||
|
// @Success 200 {object} boolResponse
|
||||||
|
// @Failure 400 {object} stringResponse
|
||||||
|
// @Failure 500 {object} stringResponse
|
||||||
|
// @Router /profiles/referral/{profile}/{invite} [post]
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Profiles & Settings
|
||||||
|
func (app *appContext) EnableReferralForProfile(gc *gin.Context) {
|
||||||
|
profileName := gc.Param("profile")
|
||||||
|
invCode := gc.Param("invite")
|
||||||
|
inv, ok := app.storage.GetInvitesKey(invCode)
|
||||||
|
if !ok {
|
||||||
|
respond(400, "Invalid invite code", gc)
|
||||||
|
app.err.Printf("\"%s\": Failed to enable referrals: invite not found", profileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, ok := app.storage.GetProfileKey(profileName)
|
||||||
|
if !ok {
|
||||||
|
respond(400, "Invalid profile", gc)
|
||||||
|
app.err.Printf("\"%s\": Failed to enable referrals: profile not found", profileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new code for referral template
|
||||||
|
inv.Code = shortuuid.New()
|
||||||
|
// make sure code doesn't begin with number
|
||||||
|
_, err := strconv.Atoi(string(inv.Code[0]))
|
||||||
|
for err == nil {
|
||||||
|
inv.Code = shortuuid.New()
|
||||||
|
_, err = strconv.Atoi(string(inv.Code[0]))
|
||||||
|
}
|
||||||
|
inv.Created = time.Now()
|
||||||
|
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||||
|
inv.IsReferral = true
|
||||||
|
// Since this is a template for multiple users, ReferrerJellyfinID is not set.
|
||||||
|
// inv.ReferrerJellyfinID = ...
|
||||||
|
|
||||||
|
app.storage.SetInvitesKey(inv.Code, inv)
|
||||||
|
|
||||||
|
profile.ReferralTemplateKey = inv.Code
|
||||||
|
|
||||||
|
app.storage.SetProfileKey(profile.Name, profile)
|
||||||
|
|
||||||
|
respondBool(200, true, gc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Disable referrals for a profile, and removes the referral template. no-op if not enabled.
|
||||||
|
// @Produce json
|
||||||
|
// @Param profile path string true "name of profile to enable referrals for."
|
||||||
|
// @Success 200 {object} boolResponse
|
||||||
|
// @Router /profiles/referral/{profile} [delete]
|
||||||
|
// @Security Bearer
|
||||||
|
// @tags Profiles & Settings
|
||||||
|
func (app *appContext) DisableReferralForProfile(gc *gin.Context) {
|
||||||
|
profileName := gc.Param("profile")
|
||||||
|
profile, ok := app.storage.GetProfileKey(profileName)
|
||||||
|
if !ok {
|
||||||
|
respondBool(200, true, gc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.storage.DeleteInvitesKey(profile.ReferralTemplateKey)
|
||||||
|
|
||||||
|
profile.ReferralTemplateKey = ""
|
||||||
|
|
||||||
|
app.storage.SetProfileKey(profileName, profile)
|
||||||
|
|
||||||
|
respondBool(200, true, gc)
|
||||||
|
}
|
||||||
|
@ -643,10 +643,10 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
|
|||||||
// If one exists, that means its just for us and so we
|
// If one exists, that means its just for us and so we
|
||||||
// can use it directly.
|
// can use it directly.
|
||||||
inv := Invite{}
|
inv := Invite{}
|
||||||
err := app.storage.db.Find(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
|
err := app.storage.db.FindOne(&inv, badgerhold.Where("ReferrerJellyfinID").Eq(gc.GetString("jfId")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 2. Look for a template matching the key found in the user storage
|
// 2. Look for a template matching the key found in the user storage
|
||||||
// Since this key is shared between a profile, we make a copy.
|
// Since this key is shared between users in a profile, we make a copy.
|
||||||
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
user, ok := app.storage.GetEmailsKey(gc.GetString("jfId"))
|
||||||
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
err = app.storage.db.Get(user.ReferralTemplateKey, &inv)
|
||||||
if !ok || err != nil {
|
if !ok || err != nil {
|
||||||
@ -664,6 +664,7 @@ func (app *appContext) GetMyReferral(gc *gin.Context) {
|
|||||||
inv.Created = time.Now()
|
inv.Created = time.Now()
|
||||||
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
inv.ValidTill = inv.Created.Add(REFERRAL_EXPIRY_DAYS * 24 * time.Hour)
|
||||||
inv.IsReferral = true
|
inv.IsReferral = true
|
||||||
|
inv.ReferrerJellyfinID = gc.GetString("jfId")
|
||||||
app.storage.SetInvitesKey(inv.Code, inv)
|
app.storage.SetInvitesKey(inv.Code, inv)
|
||||||
} else if time.Now().After(inv.ValidTill) {
|
} else if time.Now().After(inv.ValidTill) {
|
||||||
// 3. We found an invite for us, but it's expired.
|
// 3. We found an invite for us, but it's expired.
|
||||||
|
28
api-users.go
28
api-users.go
@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt"
|
||||||
"github.com/hrfee/mediabrowser"
|
"github.com/hrfee/mediabrowser"
|
||||||
"github.com/lithammer/shortuuid/v3"
|
"github.com/lithammer/shortuuid/v3"
|
||||||
|
"github.com/timshannon/badgerhold/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Summary Creates a new Jellyfin user without an invite.
|
// @Summary Creates a new Jellyfin user without an invite.
|
||||||
@ -657,6 +658,7 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
|
|||||||
return
|
return
|
||||||
|
|
||||||
}
|
}
|
||||||
|
app.debug.Printf("Found referral template in profile: %+v\n", profile.ReferralTemplateKey)
|
||||||
} else if mode == "invite" {
|
} else if mode == "invite" {
|
||||||
// Get the invite, and modify it to turn it into a referral
|
// Get the invite, and modify it to turn it into a referral
|
||||||
err := app.storage.db.Get(source, &baseInv)
|
err := app.storage.db.Get(source, &baseInv)
|
||||||
@ -667,6 +669,10 @@ func (app *appContext) EnableReferralForUsers(gc *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, u := range req.Users {
|
for _, u := range req.Users {
|
||||||
|
// 1. Wipe out any existing referral codes.
|
||||||
|
app.storage.db.DeleteMatching(Invite{}, badgerhold.Where("ReferrerJellyfinID").Eq(u))
|
||||||
|
|
||||||
|
// 2. Generate referral invite.
|
||||||
inv := baseInv
|
inv := baseInv
|
||||||
inv.Code = shortuuid.New()
|
inv.Code = shortuuid.New()
|
||||||
// make sure code doesn't begin with number
|
// make sure code doesn't begin with number
|
||||||
@ -887,13 +893,15 @@ func (app *appContext) GetUsers(gc *gin.Context) {
|
|||||||
}
|
}
|
||||||
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
adminOnly := app.config.Section("ui").Key("admin_only").MustBool(true)
|
||||||
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
|
allowAll := app.config.Section("ui").Key("allow_all").MustBool(false)
|
||||||
|
referralsEnabled := app.config.Section("user_page").Key("referrals").MustBool(false)
|
||||||
i := 0
|
i := 0
|
||||||
for _, jfUser := range users {
|
for _, jfUser := range users {
|
||||||
user := respUser{
|
user := respUser{
|
||||||
ID: jfUser.ID,
|
ID: jfUser.ID,
|
||||||
Name: jfUser.Name,
|
Name: jfUser.Name,
|
||||||
Admin: jfUser.Policy.IsAdministrator,
|
Admin: jfUser.Policy.IsAdministrator,
|
||||||
Disabled: jfUser.Policy.IsDisabled,
|
Disabled: jfUser.Policy.IsDisabled,
|
||||||
|
ReferralsEnabled: false,
|
||||||
}
|
}
|
||||||
if !jfUser.LastActivityDate.IsZero() {
|
if !jfUser.LastActivityDate.IsZero() {
|
||||||
user.LastActive = jfUser.LastActivityDate.Unix()
|
user.LastActive = jfUser.LastActivityDate.Unix()
|
||||||
@ -922,6 +930,18 @@ func (app *appContext) GetUsers(gc *gin.Context) {
|
|||||||
user.DiscordID = dcUser.ID
|
user.DiscordID = dcUser.ID
|
||||||
user.NotifyThroughDiscord = dcUser.Contact
|
user.NotifyThroughDiscord = dcUser.Contact
|
||||||
}
|
}
|
||||||
|
// FIXME: Send referral data
|
||||||
|
referrerInv := Invite{}
|
||||||
|
if referralsEnabled {
|
||||||
|
// 1. Directly attached invite.
|
||||||
|
err := app.storage.db.FindOne(&referrerInv, badgerhold.Where("ReferrerJellyfinID").Eq(jfUser.ID))
|
||||||
|
if err == nil {
|
||||||
|
user.ReferralsEnabled = true
|
||||||
|
// 2. Referrals via profile template. Shallow check, doesn't look for the thing in the database.
|
||||||
|
} else if email, ok := app.storage.GetEmailsKey(jfUser.ID); ok && email.ReferralTemplateKey != "" {
|
||||||
|
user.ReferralsEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
resp.UserList[i] = user
|
resp.UserList[i] = user
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
@ -385,7 +385,7 @@
|
|||||||
"enabled": {
|
"enabled": {
|
||||||
"name": "Enabled",
|
"name": "Enabled",
|
||||||
"required": false,
|
"required": false,
|
||||||
"requires_restart": false,
|
"requires_restart": true,
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"value": true
|
"value": true
|
||||||
},
|
},
|
||||||
@ -409,7 +409,7 @@
|
|||||||
"referrals": {
|
"referrals": {
|
||||||
"name": "User Referrals",
|
"name": "User Referrals",
|
||||||
"required": false,
|
"required": false,
|
||||||
"requires_restart": false,
|
"requires_restart": true,
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"value": true,
|
"value": true,
|
||||||
"description": "Users are given their own \"invite\" to send to others."
|
"description": "Users are given their own \"invite\" to send to others."
|
||||||
|
@ -135,6 +135,20 @@
|
|||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modal-enable-referrals-profile" class="modal">
|
||||||
|
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-enable-referrals-profile" href="">
|
||||||
|
<span class="heading"><span id="header-enable-referrals-profile">{{ .strings.enableReferrals }}</span> <span class="modal-close">×</span></span>
|
||||||
|
<p class="content my-4">{{ .strings.enableReferralsProfileDescription }}</p>
|
||||||
|
<label class="supra" for="enable-referrals-profile-invites">{{ .strings.invite }}</label>
|
||||||
|
<div class="select ~neutral @low mb-4 mt-2">
|
||||||
|
<select id="enable-referrals-profile-invites"></select>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input type="submit" class="unfocused">
|
||||||
|
<span class="button ~urge @low full-width center supra submit">{{ .strings.apply }}</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div id="modal-delete-user" class="modal">
|
<div id="modal-delete-user" class="modal">
|
||||||
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
|
<form class="card relative mx-auto my-[10%] w-4/5 lg:w-1/3" id="form-delete-user" href="">
|
||||||
@ -332,6 +346,9 @@
|
|||||||
{{ if .ombiEnabled }}
|
{{ if .ombiEnabled }}
|
||||||
<th>Ombi</th>
|
<th>Ombi</th>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .referralsEnabled }}
|
||||||
|
<th>{{ .strings.referrals }}</th>
|
||||||
|
{{ end }}
|
||||||
<th>{{ .strings.from }}</th>
|
<th>{{ .strings.from }}</th>
|
||||||
<th>{{ .strings.userProfilesLibraries }}</th>
|
<th>{{ .strings.userProfilesLibraries }}</th>
|
||||||
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
|
<th><span class="button ~neutral @high" id="button-profile-create">{{ .strings.create }}</span></th>
|
||||||
@ -642,7 +659,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
<span class="col button ~urge @low center max-w-[20%]" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
|
||||||
<span class="col button ~urge @low center max-w-[20%]" id="accounts-enable-referrals">{{ .strings.enableReferrals }}</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>
|
<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">
|
<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>
|
<span class="w-100 button ~positive @low center" id="accounts-disable-enable">{{ .strings.disable }}</span>
|
||||||
@ -674,6 +693,9 @@
|
|||||||
{{ if .discordEnabled }}
|
{{ if .discordEnabled }}
|
||||||
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
|
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-discord">Discord</th>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .referralsEnabled }}
|
||||||
|
<th class="text-center-i grid gap-4 place-items-stretch accounts-header-referrals">{{ .strings.referrals }}</th>
|
||||||
|
{{ end }}
|
||||||
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
|
<th class="grid gap-4 place-items-stretch accounts-header-expiry">{{ .strings.expiry }}</th>
|
||||||
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
|
<th class="grid gap-4 place-items-stretch accounts-header-last-active">{{ .strings.lastActiveTime }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -65,7 +65,8 @@
|
|||||||
"modifySettings": "Modify Settings",
|
"modifySettings": "Modify Settings",
|
||||||
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
|
||||||
"enableReferrals": "Enable Referrals",
|
"enableReferrals": "Enable Referrals",
|
||||||
"enableReferralsDescription": "Give users their a referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an exsiting invite.",
|
"enableReferralsDescription": "Give users a personal referral link similiar to an invite, to send to friends/family. Can be sourced from a referral template in a profile, or from an existing invite.",
|
||||||
|
"enableReferralsProfileDescription": "Give users created with this profile a personal referral link similiar to an invite, to send to friends/family. Create an invite with the desired settings, then select it here. Each referral will then be based on this invite. You can delete the invite once complete.",
|
||||||
"applyHomescreenLayout": "Apply homescreen layout",
|
"applyHomescreenLayout": "Apply homescreen layout",
|
||||||
"sendDeleteNotificationEmail": "Send notification message",
|
"sendDeleteNotificationEmail": "Send notification message",
|
||||||
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
"sendDeleteNotifiationExample": "Your account has been deleted.",
|
||||||
@ -135,6 +136,7 @@
|
|||||||
"updateAppliedRefresh": "Update applied, please refresh.",
|
"updateAppliedRefresh": "Update applied, please refresh.",
|
||||||
"telegramVerified": "Telegram account verified.",
|
"telegramVerified": "Telegram account verified.",
|
||||||
"accountConnected": "Account connected.",
|
"accountConnected": "Account connected.",
|
||||||
|
"referralsEnabled": "Referrals enabled.",
|
||||||
"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.",
|
||||||
|
@ -39,7 +39,8 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"myAccount": "My Account"
|
"myAccount": "My Account",
|
||||||
|
"referrals": "Referrals"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"errorLoginBlank": "The username and/or password were left blank.",
|
"errorLoginBlank": "The username and/or password were left blank.",
|
||||||
|
13
models.go
13
models.go
@ -71,10 +71,11 @@ type inviteProfileDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type profileDTO struct {
|
type profileDTO struct {
|
||||||
Admin bool `json:"admin" example:"false"` // Whether profile has admin rights or not
|
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
|
LibraryAccess string `json:"libraries" example:"all"` // Number of libraries profile has access to
|
||||||
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
|
FromUser string `json:"fromUser" example:"jeff"` // The user the profile is based on
|
||||||
Ombi bool `json:"ombi"` // Whether or not Ombi settings are stored in this profile.
|
Ombi bool `json:"ombi"` // Whether or not Ombi settings are stored in this profile.
|
||||||
|
ReferralsEnabled bool `json:"referrals_enabled" example:"true"` // Whether or not the profile has referrals enabled, and has a template invite stored.
|
||||||
}
|
}
|
||||||
|
|
||||||
type getProfilesDTO struct {
|
type getProfilesDTO struct {
|
||||||
@ -150,6 +151,7 @@ type respUser struct {
|
|||||||
NotifyThroughMatrix bool `json:"notify_matrix"`
|
NotifyThroughMatrix bool `json:"notify_matrix"`
|
||||||
Label string `json:"label"` // Label of user, shown next to their name.
|
Label string `json:"label"` // Label of user, shown next to their name.
|
||||||
AccountsAdmin bool `json:"accounts_admin"` // Whether or not the user is a jfa-go admin.
|
AccountsAdmin bool `json:"accounts_admin"` // Whether or not the user is a jfa-go admin.
|
||||||
|
ReferralsEnabled bool `json:"referrals_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type getUsersDTO struct {
|
type getUsersDTO struct {
|
||||||
@ -423,6 +425,5 @@ type GetMyReferralRespDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EnableDisableReferralDTO struct {
|
type EnableDisableReferralDTO struct {
|
||||||
Users []string `json:"users"`
|
Users []string `json:"users"`
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
}
|
||||||
|
@ -228,6 +228,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
|
|||||||
api.POST(p+"/matrix/login", app.MatrixLogin)
|
api.POST(p+"/matrix/login", app.MatrixLogin)
|
||||||
if app.config.Section("user_page").Key("referrals").MustBool(false) {
|
if app.config.Section("user_page").Key("referrals").MustBool(false) {
|
||||||
api.POST(p+"/users/referral/:mode/:source", app.EnableReferralForUsers)
|
api.POST(p+"/users/referral/:mode/:source", app.EnableReferralForUsers)
|
||||||
|
api.POST(p+"/profiles/referral/:profile/:invite", app.EnableReferralForProfile)
|
||||||
|
api.DELETE(p+"/profiles/referral/:profile", app.DisableReferralForProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if userPageEnabled {
|
if userPageEnabled {
|
||||||
|
@ -81,6 +81,7 @@ window.availableProfiles = window.availableProfiles || [];
|
|||||||
|
|
||||||
if (window.referralsEnabled) {
|
if (window.referralsEnabled) {
|
||||||
window.modals.enableReferralsUser = new Modal(document.getElementById("modal-enable-referrals-user"));
|
window.modals.enableReferralsUser = new Modal(document.getElementById("modal-enable-referrals-user"));
|
||||||
|
window.modals.enableReferralsProfile = new Modal(document.getElementById("modal-enable-referrals-profile"));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ interface User {
|
|||||||
notify_matrix: boolean;
|
notify_matrix: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
accounts_admin: boolean;
|
accounts_admin: boolean;
|
||||||
|
referrals_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface getPinResponse {
|
interface getPinResponse {
|
||||||
@ -69,6 +70,8 @@ class user implements User {
|
|||||||
private _labelEditButton: HTMLElement;
|
private _labelEditButton: HTMLElement;
|
||||||
private _accounts_admin: HTMLInputElement
|
private _accounts_admin: HTMLInputElement
|
||||||
private _selected: boolean;
|
private _selected: boolean;
|
||||||
|
private _referralsEnabled: boolean;
|
||||||
|
private _referralsEnabledCheck: HTMLElement;
|
||||||
|
|
||||||
lastNotifyMethod = (): string => {
|
lastNotifyMethod = (): string => {
|
||||||
// Telegram, Matrix, Discord
|
// Telegram, Matrix, Discord
|
||||||
@ -162,6 +165,17 @@ class user implements User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get referrals_enabled(): boolean { return this._referralsEnabled; }
|
||||||
|
set referrals_enabled(v: boolean) {
|
||||||
|
this._referralsEnabled = v;
|
||||||
|
if (!window.referralsEnabled) return;
|
||||||
|
if (!v) {
|
||||||
|
this._referralsEnabledCheck.textContent = ``;
|
||||||
|
} else {
|
||||||
|
this._referralsEnabledCheck.innerHTML = `<i class="ri-check-line" aria-label="${window.lang.strings("enabled")}"></i>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private _constructDropdown = (): HTMLDivElement => {
|
private _constructDropdown = (): HTMLDivElement => {
|
||||||
const el = document.createElement("div") as HTMLDivElement;
|
const el = document.createElement("div") as HTMLDivElement;
|
||||||
const telegram = this._telegramUsername != "";
|
const telegram = this._telegramUsername != "";
|
||||||
@ -506,6 +520,11 @@ class user implements User {
|
|||||||
<td class="accounts-discord"></td>
|
<td class="accounts-discord"></td>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
if (window.referralsEnabled) {
|
||||||
|
innerHTML += `
|
||||||
|
<td class="accounts-referrals text-center-i grid gap-4 place-items-stretch"></td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
innerHTML += `
|
innerHTML += `
|
||||||
<td class="accounts-expiry"></td>
|
<td class="accounts-expiry"></td>
|
||||||
<td class="accounts-last-active whitespace-nowrap"></td>
|
<td class="accounts-last-active whitespace-nowrap"></td>
|
||||||
@ -544,6 +563,10 @@ class user implements User {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.referralsEnabled) {
|
||||||
|
this._referralsEnabledCheck = this._row.querySelector(".accounts-referrals");
|
||||||
|
}
|
||||||
|
|
||||||
this._notifyDropdown = this._constructDropdown();
|
this._notifyDropdown = this._constructDropdown();
|
||||||
|
|
||||||
@ -716,6 +739,7 @@ class user implements User {
|
|||||||
this.discord_id = user.discord_id;
|
this.discord_id = user.discord_id;
|
||||||
this.label = user.label;
|
this.label = user.label;
|
||||||
this.accounts_admin = user.accounts_admin;
|
this.accounts_admin = user.accounts_admin;
|
||||||
|
this.referrals_enabled = user.referrals_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
asElement = (): HTMLTableRowElement => { return this._row; }
|
asElement = (): HTMLTableRowElement => { return this._row; }
|
||||||
@ -1061,7 +1085,7 @@ export class accountsList {
|
|||||||
|
|
||||||
let attempt: { year?: number, month?: number, day?: number, hour?: number, minute?: number } = dateParser.attempt(split[1]);
|
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
|
// Month in Date objects is 0-based, so make our parsed date that way too
|
||||||
if ("month" in attempt) attempt["month"] -= 1;
|
if ("month" in attempt) attempt.month -= 1;
|
||||||
|
|
||||||
let date: Date = (Date as any).fromString(split[1]) as Date;
|
let date: Date = (Date as any).fromString(split[1]) as Date;
|
||||||
console.log("Read", attempt, "and", date);
|
console.log("Read", attempt, "and", date);
|
||||||
@ -1127,7 +1151,7 @@ export class accountsList {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -1693,11 +1717,12 @@ export class accountsList {
|
|||||||
}
|
}
|
||||||
innerHTML += `<option value="${inv.code}">${name}</option>`;
|
innerHTML += `<option value="${inv.code}">${name}</option>`;
|
||||||
}
|
}
|
||||||
|
this._enableReferralsInvite.checked = true;
|
||||||
} else {
|
} else {
|
||||||
this._enableReferralsInvite.checked = false;
|
this._enableReferralsInvite.checked = false;
|
||||||
this._enableReferralsProfile.checked = true;
|
|
||||||
innerHTML += `<option>${window.lang.strings("inviteNoInvites")}</option>`;
|
innerHTML += `<option>${window.lang.strings("inviteNoInvites")}</option>`;
|
||||||
}
|
}
|
||||||
|
this._enableReferralsProfile.checked = !(this._enableReferralsInvite.checked);
|
||||||
this._referralsInviteSelect.innerHTML = innerHTML;
|
this._referralsInviteSelect.innerHTML = innerHTML;
|
||||||
|
|
||||||
// 2. Profiles
|
// 2. Profiles
|
||||||
@ -1710,20 +1735,15 @@ export class accountsList {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// FIXME: Collect Profiles, Invite
|
|
||||||
(() => {
|
|
||||||
})();
|
|
||||||
|
|
||||||
const form = document.getElementById("form-enable-referrals-user") as HTMLFormElement;
|
const form = document.getElementById("form-enable-referrals-user") as HTMLFormElement;
|
||||||
const button = form.querySelector("span.submit") as HTMLSpanElement;
|
const button = form.querySelector("span.submit") as HTMLSpanElement;
|
||||||
this._enableReferralsProfile.checked = true;
|
|
||||||
this._enableReferralsInvite.checked = false;
|
|
||||||
form.onsubmit = (event: Event) => {
|
form.onsubmit = (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleLoader(button);
|
toggleLoader(button);
|
||||||
let send = {
|
let send = {
|
||||||
"users": list
|
"users": list
|
||||||
};
|
};
|
||||||
|
// console.log("profile:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked);
|
||||||
if (this._enableReferralsProfile.checked && !this._enableReferralsInvite.checked) {
|
if (this._enableReferralsProfile.checked && !this._enableReferralsInvite.checked) {
|
||||||
send["from"] = "profile";
|
send["from"] = "profile";
|
||||||
send["profile"] = this._referralsProfileSelect.value;
|
send["profile"] = this._referralsProfileSelect.value;
|
||||||
@ -1731,7 +1751,7 @@ export class accountsList {
|
|||||||
send["from"] = "invite";
|
send["from"] = "invite";
|
||||||
send["id"] = this._referralsInviteSelect.value;
|
send["id"] = this._referralsInviteSelect.value;
|
||||||
}
|
}
|
||||||
_post("/users/referrals/" + send["from"] + "/" + send["id"], send, (req: XMLHttpRequest) => {
|
_post("/users/referral/" + send["from"] + "/" + (send["id"] ? send["id"] : send["profile"]), send, (req: XMLHttpRequest) => {
|
||||||
if (req.readyState == 4) {
|
if (req.readyState == 4) {
|
||||||
toggleLoader(button);
|
toggleLoader(button);
|
||||||
if (req.status == 400) {
|
if (req.status == 400) {
|
||||||
@ -1744,6 +1764,8 @@ export class accountsList {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
this._enableReferralsProfile.checked = true;
|
||||||
|
this._enableReferralsInvite.checked = false;
|
||||||
window.modals.enableReferralsUser.show();
|
window.modals.enableReferralsUser.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1879,10 +1901,10 @@ export class accountsList {
|
|||||||
this._modifySettingsUser.onchange = checkSource;
|
this._modifySettingsUser.onchange = checkSource;
|
||||||
|
|
||||||
if (window.referralsEnabled) {
|
if (window.referralsEnabled) {
|
||||||
this._enableReferrals.onclick = this.enableReferrals;
|
|
||||||
const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement;
|
const profileSpan = this._enableReferralsProfile.nextElementSibling as HTMLSpanElement;
|
||||||
const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement;
|
const inviteSpan = this._enableReferralsInvite.nextElementSibling as HTMLSpanElement;
|
||||||
const checkReferralSource = () => {
|
const checkReferralSource = () => {
|
||||||
|
console.log("States:", this._enableReferralsProfile.checked, this._enableReferralsInvite.checked);
|
||||||
if (this._enableReferralsProfile.checked) {
|
if (this._enableReferralsProfile.checked) {
|
||||||
this._referralsInviteSelect.parentElement.classList.add("unfocused");
|
this._referralsInviteSelect.parentElement.classList.add("unfocused");
|
||||||
this._referralsProfileSelect.parentElement.classList.remove("unfocused")
|
this._referralsProfileSelect.parentElement.classList.remove("unfocused")
|
||||||
@ -1899,9 +1921,20 @@ export class accountsList {
|
|||||||
profileSpan.classList.add("@low");
|
profileSpan.classList.add("@low");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this._enableReferralsProfile.onchange = checkReferralSource;
|
profileSpan.onclick = () => {
|
||||||
this._enableReferralsInvite.onchange = checkReferralSource;
|
this._enableReferralsProfile.checked = true;
|
||||||
checkReferralSource();
|
this._enableReferralsInvite.checked = false;
|
||||||
|
checkReferralSource();
|
||||||
|
};
|
||||||
|
inviteSpan.onclick = () => {;
|
||||||
|
this._enableReferralsInvite.checked = true;
|
||||||
|
this._enableReferralsProfile.checked = false;
|
||||||
|
checkReferralSource();
|
||||||
|
};
|
||||||
|
this._enableReferrals.onclick = () => {
|
||||||
|
this.enableReferrals();
|
||||||
|
profileSpan.onclick(null);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this._deleteUser.onclick = this.deleteUsers;
|
this._deleteUser.onclick = this.deleteUsers;
|
||||||
|
@ -5,6 +5,7 @@ interface Profile {
|
|||||||
libraries: string;
|
libraries: string;
|
||||||
fromUser: string;
|
fromUser: string;
|
||||||
ombi: boolean;
|
ombi: boolean;
|
||||||
|
referrals_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class profile implements Profile {
|
class profile implements Profile {
|
||||||
@ -16,6 +17,8 @@ class profile implements Profile {
|
|||||||
private _fromUser: HTMLTableDataCellElement;
|
private _fromUser: HTMLTableDataCellElement;
|
||||||
private _defaultRadio: HTMLInputElement;
|
private _defaultRadio: HTMLInputElement;
|
||||||
private _ombi: boolean;
|
private _ombi: boolean;
|
||||||
|
private _referralsButton: HTMLSpanElement;
|
||||||
|
private _referralsEnabled: boolean;
|
||||||
|
|
||||||
get name(): string { return this._name.textContent; }
|
get name(): string { return this._name.textContent; }
|
||||||
set name(v: string) { this._name.textContent = v; }
|
set name(v: string) { this._name.textContent = v; }
|
||||||
@ -51,7 +54,22 @@ class profile implements Profile {
|
|||||||
|
|
||||||
get fromUser(): string { return this._fromUser.textContent; }
|
get fromUser(): string { return this._fromUser.textContent; }
|
||||||
set fromUser(v: string) { this._fromUser.textContent = v; }
|
set fromUser(v: string) { this._fromUser.textContent = v; }
|
||||||
|
|
||||||
|
get referrals_enabled(): boolean { return this._referralsEnabled; }
|
||||||
|
set referrals_enabled(v: boolean) {
|
||||||
|
if (!window.referralsEnabled) return;
|
||||||
|
this._referralsEnabled = v;
|
||||||
|
if (v) {
|
||||||
|
this._referralsButton.textContent = window.lang.strings("delete");
|
||||||
|
this._referralsButton.classList.add("~critical");
|
||||||
|
this._referralsButton.classList.remove("~neutral");
|
||||||
|
} else {
|
||||||
|
this._referralsButton.textContent = window.lang.strings("add");
|
||||||
|
this._referralsButton.classList.add("~neutral");
|
||||||
|
this._referralsButton.classList.remove("~critical");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get default(): boolean { return this._defaultRadio.checked; }
|
get default(): boolean { return this._defaultRadio.checked; }
|
||||||
set default(v: boolean) { this._defaultRadio.checked = v; }
|
set default(v: boolean) { this._defaultRadio.checked = v; }
|
||||||
|
|
||||||
@ -64,6 +82,9 @@ class profile implements Profile {
|
|||||||
if (window.ombiEnabled) innerHTML += `
|
if (window.ombiEnabled) innerHTML += `
|
||||||
<td><span class="button @low profile-ombi"></span></td>
|
<td><span class="button @low profile-ombi"></span></td>
|
||||||
`;
|
`;
|
||||||
|
if (window.referralsEnabled) innerHTML += `
|
||||||
|
<td><span class="button @low profile-referrals"></span></td>
|
||||||
|
`;
|
||||||
innerHTML += `
|
innerHTML += `
|
||||||
<td class="profile-from ellipsis"></td>
|
<td class="profile-from ellipsis"></td>
|
||||||
<td class="profile-libraries"></td>
|
<td class="profile-libraries"></td>
|
||||||
@ -75,6 +96,8 @@ class profile implements Profile {
|
|||||||
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
|
this._libraries = this._row.querySelector("td.profile-libraries") as HTMLTableDataCellElement;
|
||||||
if (window.ombiEnabled)
|
if (window.ombiEnabled)
|
||||||
this._ombiButton = this._row.querySelector("span.profile-ombi") as HTMLSpanElement;
|
this._ombiButton = this._row.querySelector("span.profile-ombi") as HTMLSpanElement;
|
||||||
|
if (window.referralsEnabled)
|
||||||
|
this._referralsButton = this._row.querySelector("span.profile-referrals") as HTMLSpanElement;
|
||||||
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
|
this._fromUser = this._row.querySelector("td.profile-from") as HTMLTableDataCellElement;
|
||||||
this._defaultRadio = this._row.querySelector("input[type=radio]") as HTMLInputElement;
|
this._defaultRadio = this._row.querySelector("input[type=radio]") as HTMLInputElement;
|
||||||
this._defaultRadio.onclick = () => document.dispatchEvent(new CustomEvent("profiles-default", { detail: this.name }));
|
this._defaultRadio.onclick = () => document.dispatchEvent(new CustomEvent("profiles-default", { detail: this.name }));
|
||||||
@ -89,9 +112,11 @@ class profile implements Profile {
|
|||||||
this.fromUser = p.fromUser;
|
this.fromUser = p.fromUser;
|
||||||
this.libraries = p.libraries;
|
this.libraries = p.libraries;
|
||||||
this.ombi = p.ombi;
|
this.ombi = p.ombi;
|
||||||
|
this.referrals_enabled = p.referrals_enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOmbiFunc = (ombiFunc: (ombi: boolean) => void) => { this._ombiButton.onclick = () => ombiFunc(this._ombi); }
|
setOmbiFunc = (ombiFunc: (ombi: boolean) => void) => { this._ombiButton.onclick = () => ombiFunc(this._ombi); }
|
||||||
|
setReferralFunc = (referralFunc: (enabled: boolean) => void) => { this._referralsButton.onclick = () => referralFunc(this._referralsEnabled); }
|
||||||
|
|
||||||
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
|
remove = () => { document.dispatchEvent(new CustomEvent("profiles-delete", { detail: this._name })); this._row.remove(); }
|
||||||
|
|
||||||
@ -173,6 +198,14 @@ export class ProfileEditor {
|
|||||||
this._ombiProfiles.load(name);
|
this._ombiProfiles.load(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (window.referralsEnabled)
|
||||||
|
this._profiles[name].setReferralFunc((enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
this.disableReferrals(name);
|
||||||
|
} else {
|
||||||
|
this.enableReferrals(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
this._table.appendChild(this._profiles[name].asElement());
|
this._table.appendChild(this._profiles[name].asElement());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,6 +218,62 @@ export class ProfileEditor {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
disableReferrals = (name: string) => _delete("/profiles/referral/" + name, null, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState != 4) return;
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
enableReferrals = (name: string) => {
|
||||||
|
const referralsInviteSelect = document.getElementById("enable-referrals-profile-invites") as HTMLSelectElement;
|
||||||
|
_get("/invites", null, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState != 4 || req.status != 200) return;
|
||||||
|
|
||||||
|
let innerHTML = "";
|
||||||
|
let invites = req.response["invites"] as Array<Invite>;
|
||||||
|
window.availableProfiles = req.response["profiles"];
|
||||||
|
if (invites) {
|
||||||
|
for (let inv of invites) {
|
||||||
|
let name = inv.code;
|
||||||
|
if (inv.label) {
|
||||||
|
name = `${inv.label} (${inv.code})`;
|
||||||
|
}
|
||||||
|
innerHTML += `<option value="${inv.code}">${name}</option>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
innerHTML += `<option>${window.lang.strings("inviteNoInvites")}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
referralsInviteSelect.innerHTML = innerHTML;
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.getElementById("form-enable-referrals-profile") as HTMLFormElement;
|
||||||
|
const button = form.querySelector("span.submit") as HTMLSpanElement;
|
||||||
|
form.onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleLoader(button);
|
||||||
|
|
||||||
|
let send = {
|
||||||
|
"profile": name,
|
||||||
|
"invite": referralsInviteSelect.value
|
||||||
|
};
|
||||||
|
|
||||||
|
_post("/profiles/referral/" + send["profile"] + "/" + send["invite"], send, (req: XMLHttpRequest) => {
|
||||||
|
if (req.readyState == 4) {
|
||||||
|
toggleLoader(button);
|
||||||
|
if (req.status == 400) {
|
||||||
|
window.notifications.customError("unknownError", window.lang.notif("errorUnknown"));
|
||||||
|
} else if (req.status == 200 || req.status == 204) {
|
||||||
|
window.notifications.customSuccess("enableReferralsSuccess", window.lang.notif("referralsEnabled"));
|
||||||
|
}
|
||||||
|
window.modals.enableReferralsProfile.close();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.modals.profiles.close();
|
||||||
|
window.modals.enableReferralsProfile.show();
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
(document.getElementById('setting-profiles') as HTMLSpanElement).onclick = this.load;
|
(document.getElementById('setting-profiles') as HTMLSpanElement).onclick = this.load;
|
||||||
document.addEventListener("profiles-default", (event: CustomEvent) => {
|
document.addEventListener("profiles-default", (event: CustomEvent) => {
|
||||||
|
@ -115,6 +115,7 @@ declare interface Modals {
|
|||||||
logs: Modal;
|
logs: Modal;
|
||||||
email?: Modal;
|
email?: Modal;
|
||||||
enableReferralsUser?: Modal;
|
enableReferralsUser?: Modal;
|
||||||
|
enableReferralsProfile?: Modal;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Invite {
|
interface Invite {
|
||||||
|
Loading…
Reference in New Issue
Block a user