diff --git a/badgesmodel/constants.go b/badgesmodel/constants.go index 7a4f249..f4bc1cb 100644 --- a/badgesmodel/constants.go +++ b/badgesmodel/constants.go @@ -3,6 +3,7 @@ package badgesmodel const ( NameMaxLength = 20 DescriptionMaxLength = 120 + DefaultTypeName = "Общий" ImageTypeEmoji ImageType = "emoji" ImageTypeRelativeURL ImageType = "rel_url" diff --git a/badgesmodel/model.go b/badgesmodel/model.go index 3ed98ea..51d0e26 100644 --- a/badgesmodel/model.go +++ b/badgesmodel/model.go @@ -2,6 +2,7 @@ package badgesmodel import ( "time" + "unicode/utf8" ) type BadgeType string @@ -56,6 +57,7 @@ type BadgeTypeDefinition struct { CreatedBy string `json:"created_by"` CanGrant PermissionScheme `json:"can_grant"` CanCreate PermissionScheme `json:"can_create"` + IsDefault bool `json:"is_default"` } type PermissionScheme struct { @@ -87,8 +89,8 @@ type Subscription struct { } func (b Badge) IsValid() bool { - return len(b.Name) <= NameMaxLength && - len(b.Description) <= DescriptionMaxLength && + return utf8.RuneCountInString(b.Name) <= NameMaxLength && + utf8.RuneCountInString(b.Description) <= DescriptionMaxLength && b.Image != "" } diff --git a/server/api.go b/server/api.go index 921b5bc..c610b5b 100644 --- a/server/api.go +++ b/server/api.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "errors" "net/http" "runtime/debug" "strings" @@ -32,6 +33,75 @@ type APIErrorResponse struct { StatusCode int `json:"status_code"` } +type CreateBadgeRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` + Type string `json:"type"` + Multiple bool `json:"multiple"` + ChannelID string `json:"channel_id"` +} + +type UpdateBadgeRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Image string `json:"image"` + Type string `json:"type"` + Multiple bool `json:"multiple"` +} + +type CreateTypeRequest struct { + Name string `json:"name"` + EveryoneCanCreate bool `json:"everyone_can_create"` + EveryoneCanGrant bool `json:"everyone_can_grant"` + AllowlistCanCreate string `json:"allowlist_can_create"` + AllowlistCanGrant string `json:"allowlist_can_grant"` + ChannelID string `json:"channel_id"` +} + +type UpdateTypeRequest struct { + ID string `json:"id"` + Name string `json:"name"` + EveryoneCanCreate bool `json:"everyone_can_create"` + EveryoneCanGrant bool `json:"everyone_can_grant"` + AllowlistCanCreate string `json:"allowlist_can_create"` + AllowlistCanGrant string `json:"allowlist_can_grant"` +} + +type GrantBadgeAPIRequest struct { + BadgeID string `json:"badge_id"` + UserID string `json:"user_id"` + Reason string `json:"reason"` + NotifyHere bool `json:"notify_here"` + ChannelID string `json:"channel_id"` +} + +type SubscriptionAPIRequest struct { + TypeID string `json:"type_id"` + ChannelID string `json:"channel_id"` +} + +type RevokeOwnershipRequest struct { + BadgeID string `json:"badge_id"` + UserID string `json:"user_id"` + Time string `json:"time"` +} + +type TypeWithBadgeCount struct { + *badgesmodel.BadgeTypeDefinition + BadgeCount int `json:"badge_count"` + CreatedByUsername string `json:"created_by_username"` + AllowlistCanCreate string `json:"allowlist_can_create"` + AllowlistCanGrant string `json:"allowlist_can_grant"` +} + +type GetTypesResponse struct { + Types []TypeWithBadgeCount `json:"types"` + CanCreateType bool `json:"can_create_type"` + CanEditType bool `json:"can_edit_type"` +} + func (p *Plugin) initializeAPI() { p.router = mux.NewRouter() p.router.Use(p.withRecovery) @@ -44,6 +114,18 @@ func (p *Plugin) initializeAPI() { apiRouter.HandleFunc("/getUserBadges/{userID}", p.extractUserMiddleWare(p.getUserBadges, ResponseTypeJSON)).Methods(http.MethodGet) apiRouter.HandleFunc("/getBadgeDetails/{badgeID}", p.extractUserMiddleWare(p.getBadgeDetails, ResponseTypeJSON)).Methods(http.MethodGet) apiRouter.HandleFunc("/getAllBadges", p.extractUserMiddleWare(p.getAllBadges, ResponseTypeJSON)).Methods(http.MethodGet) + apiRouter.HandleFunc("/getTypes", p.extractUserMiddleWare(p.getTypes, ResponseTypeJSON)).Methods(http.MethodGet) + apiRouter.HandleFunc("/createBadge", p.extractUserMiddleWare(p.apiCreateBadge, ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/createType", p.extractUserMiddleWare(p.apiCreateType, ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/updateBadge", p.extractUserMiddleWare(p.apiUpdateBadge, ResponseTypeJSON)).Methods(http.MethodPut) + apiRouter.HandleFunc("/updateType", p.extractUserMiddleWare(p.apiUpdateType, ResponseTypeJSON)).Methods(http.MethodPut) + apiRouter.HandleFunc("/deleteBadge/{badgeID}", p.extractUserMiddleWare(p.apiDeleteBadge, ResponseTypeJSON)).Methods(http.MethodDelete) + apiRouter.HandleFunc("/deleteType/{typeID}", p.extractUserMiddleWare(p.apiDeleteType, ResponseTypeJSON)).Methods(http.MethodDelete) + apiRouter.HandleFunc("/grantBadge", p.extractUserMiddleWare(p.apiGrantBadge, ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/revokeOwnership", p.extractUserMiddleWare(p.apiRevokeOwnership, ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/getChannelSubscriptions/{channelID}", p.extractUserMiddleWare(p.apiGetChannelSubscriptions, ResponseTypeJSON)).Methods(http.MethodGet) + apiRouter.HandleFunc("/createSubscription", p.extractUserMiddleWare(p.apiCreateSubscription, ResponseTypeJSON)).Methods(http.MethodPost) + apiRouter.HandleFunc("/deleteSubscription", p.extractUserMiddleWare(p.apiDeleteSubscription, ResponseTypeJSON)).Methods(http.MethodPost) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathEnsure, checkPluginRequest(p.ensureBadges)).Methods(http.MethodPost) pluginAPIRouter.HandleFunc(badgesmodel.PluginAPIPathGrant, checkPluginRequest(p.grantBadge)).Methods(http.MethodPost) @@ -91,6 +173,480 @@ func dialogKeepOpen(w http.ResponseWriter) { _, _ = w.Write(resp.ToJson()) } +func (p *Plugin) getTypes(w http.ResponseWriter, r *http.Request, userID string) { + u, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + types, err := p.filterCreateBadgeTypes(u) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_types", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + badges, err := p.store.GetRawBadges() + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_badges", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + badgeCountByType := map[badgesmodel.BadgeType]int{} + for _, badge := range badges { + badgeCountByType[badge.Type]++ + } + + result := make([]TypeWithBadgeCount, len(types)) + for i, t := range types { + createdByUsername := t.CreatedBy + if creator, appErr := p.mm.User.Get(t.CreatedBy); appErr == nil { + createdByUsername = creator.Username + } + result[i] = TypeWithBadgeCount{ + BadgeTypeDefinition: t, + BadgeCount: badgeCountByType[t.ID], + CreatedByUsername: createdByUsername, + AllowlistCanCreate: p.resolveUserIDList(t.CanCreate.AllowList), + AllowlistCanGrant: p.resolveUserIDList(t.CanGrant.AllowList), + } + } + + resp := GetTypesResponse{ + Types: result, + CanCreateType: canCreateType(u, p.badgeAdminUserIDs, false), + CanEditType: p.badgeAdminUserIDs[u.Id] || u.IsSystemAdmin(), + } + + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} + +func (p *Plugin) apiCreateBadge(w http.ResponseWriter, r *http.Request, userID string) { + var req CreateBadgeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + req.Image = strings.TrimSpace(req.Image) + + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + if length := len(req.Image); length > 1 && req.Image[0] == ':' && req.Image[length-1] == ':' { + req.Image = req.Image[1 : length-1] + } + if req.Image == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_image", Message: "Emoji is required", StatusCode: http.StatusBadRequest, + }) + return + } + + t, err := p.store.GetType(badgesmodel.BadgeType(req.Type)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusBadRequest, + }) + return + } + + if !canCreateBadge(user, p.badgeAdminUserIDs, t) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to create badge of this type", StatusCode: http.StatusForbidden, + }) + return + } + + toCreate := &badgesmodel.Badge{ + Name: req.Name, + Description: req.Description, + Image: req.Image, + ImageType: badgesmodel.ImageTypeEmoji, + Multiple: req.Multiple, + Type: badgesmodel.BadgeType(req.Type), + CreatedBy: userID, + } + + created, err := p.store.AddBadge(toCreate) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_create_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + if req.ChannelID != "" { + T := p.getT(user.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.badge_created", "Значок `%s` создан.", toCreate.Name), + }) + } + + b, _ := json.Marshal(created) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(b) +} + +func (p *Plugin) apiCreateType(w http.ResponseWriter, r *http.Request, userID string) { + var req CreateTypeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canCreateType(user, p.badgeAdminUserIDs, false) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to create type", StatusCode: http.StatusForbidden, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Type name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + toCreate := &badgesmodel.BadgeTypeDefinition{ + Name: req.Name, + CreatedBy: userID, + } + toCreate.CanCreate.Everyone = req.EveryoneCanCreate + toCreate.CanGrant.Everyone = req.EveryoneCanGrant + + if req.AllowlistCanCreate != "" { + allowList, aErr := p.resolveUsernameList(req.AllowlistCanCreate) + if aErr != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest, + }) + return + } + toCreate.CanCreate.AllowList = allowList + } + + if req.AllowlistCanGrant != "" { + allowList, aErr := p.resolveUsernameList(req.AllowlistCanGrant) + if aErr != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest, + }) + return + } + toCreate.CanGrant.AllowList = allowList + } + + created, err := p.store.AddType(toCreate) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_create_type", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + if req.ChannelID != "" { + T := p.getT(user.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.type_created", "Тип `%s` создан.", toCreate.Name), + }) + } + + b, _ := json.Marshal(created) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(b) +} + +func (p *Plugin) apiUpdateBadge(w http.ResponseWriter, r *http.Request, userID string) { + var req UpdateBadgeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + badge, err := p.store.GetBadge(badgesmodel.BadgeID(req.ID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound, + }) + return + } + + badgeType, err := p.store.GetType(badge.Type) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canEditBadge(user, p.badgeAdminUserIDs, badge, badgeType) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to edit this badge", StatusCode: http.StatusForbidden, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + req.Description = strings.TrimSpace(req.Description) + req.Image = strings.TrimSpace(req.Image) + + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + if length := len(req.Image); length > 1 && req.Image[0] == ':' && req.Image[length-1] == ':' { + req.Image = req.Image[1 : length-1] + } + if req.Image == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_image", Message: "Emoji is required", StatusCode: http.StatusBadRequest, + }) + return + } + + badge.Name = req.Name + badge.Description = req.Description + badge.Image = req.Image + badge.Type = badgesmodel.BadgeType(req.Type) + badge.Multiple = req.Multiple + + if err := p.store.UpdateBadge(badge); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_update_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + b, _ := json.Marshal(badge) + _, _ = w.Write(b) +} + +func (p *Plugin) apiUpdateType(w http.ResponseWriter, r *http.Request, userID string) { + var req UpdateTypeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + originalType, err := p.store.GetType(badgesmodel.BadgeType(req.ID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusNotFound, + }) + return + } + + if !canEditType(user, p.badgeAdminUserIDs, originalType) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to edit this type", StatusCode: http.StatusForbidden, + }) + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_name", Message: "Name is required", StatusCode: http.StatusBadRequest, + }) + return + } + + originalType.Name = req.Name + originalType.CanCreate.Everyone = req.EveryoneCanCreate + originalType.CanGrant.Everyone = req.EveryoneCanGrant + + createAllowList, aErr := p.resolveUsernameList(req.AllowlistCanCreate) + if aErr != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest, + }) + return + } + originalType.CanCreate.AllowList = createAllowList + + grantAllowList, aErr := p.resolveUsernameList(req.AllowlistCanGrant) + if aErr != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: aErr.Error(), StatusCode: http.StatusBadRequest, + }) + return + } + originalType.CanGrant.AllowList = grantAllowList + + if err := p.store.UpdateType(originalType); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_update_type", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + b, _ := json.Marshal(originalType) + _, _ = w.Write(b) +} + +func (p *Plugin) apiDeleteBadge(w http.ResponseWriter, r *http.Request, userID string) { + badgeID, ok := mux.Vars(r)["badgeID"] + if !ok { + p.writeAPIError(w, &APIErrorResponse{ + ID: "missing_badge_id", Message: "Missing badge ID", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound, + }) + return + } + + badgeType, err := p.store.GetType(badge.Type) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canEditBadge(user, p.badgeAdminUserIDs, badge, badgeType) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to delete this badge", StatusCode: http.StatusForbidden, + }) + return + } + + if err := p.store.DeleteBadge(badgesmodel.BadgeID(badgeID)); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + _, _ = w.Write([]byte(`{"success": true}`)) +} + +func (p *Plugin) apiDeleteType(w http.ResponseWriter, r *http.Request, userID string) { + typeID, ok := mux.Vars(r)["typeID"] + if !ok { + p.writeAPIError(w, &APIErrorResponse{ + ID: "missing_type_id", Message: "Missing type ID", StatusCode: http.StatusBadRequest, + }) + return + } + + user, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + t, err := p.store.GetType(badgesmodel.BadgeType(typeID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Type not found", StatusCode: http.StatusNotFound, + }) + return + } + + if t.IsDefault { + T := p.getT("ru") + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_default_type", Message: T("badges.api.cannot_delete_default_type", "Нельзя удалить тип по умолчанию"), StatusCode: http.StatusForbidden, + }) + return + } + + if !canEditType(user, p.badgeAdminUserIDs, t) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission", Message: "No permission to delete this type", StatusCode: http.StatusForbidden, + }) + return + } + + if err := p.store.DeleteType(badgesmodel.BadgeType(typeID)); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_type", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + _, _ = w.Write([]byte(`{"success": true}`)) +} + func (p *Plugin) dialogCreateBadge(w http.ResponseWriter, r *http.Request, userID string) { req := model.SubmitDialogRequestFromJson(r.Body) if req == nil { @@ -154,7 +710,7 @@ func (p *Plugin) dialogCreateBadge(w http.ResponseWriter, r *http.Request, userI return } - if !canCreateBadge(user, p.badgeAdminUserID, t) { + if !canCreateBadge(user, p.badgeAdminUserIDs, t) { dialogError(w, T("badges.api.no_permissions_create_badge", "У вас нет прав на создание этого значка"), nil) return } @@ -190,7 +746,7 @@ func (p *Plugin) dialogCreateType(w http.ResponseWriter, r *http.Request, userID } T := p.getT(u.Locale) - if !canCreateType(u, p.badgeAdminUserID, false) { + if !canCreateType(u, p.badgeAdminUserIDs, false) { dialogError(w, T("badges.api.no_permissions_create_type", "У вас нет прав на создание типа"), nil) return } @@ -288,7 +844,7 @@ func (p *Plugin) dialogSelectType(w http.ResponseWriter, r *http.Request, userID } T := p.getT(u.Locale) - if !canEditType(u, p.badgeAdminUserID, t) { + if !canEditType(u, p.badgeAdminUserIDs, t) { dialogError(w, T("badges.api.cannot_edit_type", "Вы не можете редактировать этот тип"), nil) return } @@ -327,12 +883,16 @@ func (p *Plugin) dialogEditType(w http.ResponseWriter, r *http.Request, userID s return } - if !canEditType(u, p.badgeAdminUserID, originalType) { + if !canEditType(u, p.badgeAdminUserIDs, originalType) { dialogError(w, T("badges.api.no_permissions_edit_type", "У вас нет прав на редактирование этого типа"), nil) return } if getDialogSubmissionBoolField(req, DialogFieldTypeDelete) { + if originalType.IsDefault { + dialogError(w, T("badges.api.cannot_delete_default_type", "Нельзя удалить тип по умолчанию"), nil) + return + } err = p.store.DeleteType(badgesmodel.BadgeType(originalTypeID)) if err != nil { dialogError(w, err.Error(), nil) @@ -428,7 +988,13 @@ func (p *Plugin) dialogSelectBadge(w http.ResponseWriter, r *http.Request, userI } T := p.getT(u.Locale) - if !canEditBadge(u, p.badgeAdminUserID, b) { + bt, err := p.store.GetType(b.Type) + if err != nil { + dialogError(w, T("badges.api.cannot_get_type", "Не удалось получить тип значка"), nil) + return + } + + if !canEditBadge(u, p.badgeAdminUserIDs, b, bt) { dialogError(w, T("badges.api.cannot_edit_badge", "Вы не можете редактировать этот значок"), nil) return } @@ -467,7 +1033,13 @@ func (p *Plugin) dialogEditBadge(w http.ResponseWriter, r *http.Request, userID return } - if !canEditBadge(u, p.badgeAdminUserID, originalBadge) { + originalBadgeType, err := p.store.GetType(originalBadge.Type) + if err != nil { + dialogError(w, T("badges.api.cannot_get_type", "Не удалось получить тип значка"), nil) + return + } + + if !canEditBadge(u, p.badgeAdminUserIDs, originalBadge, originalBadgeType) { dialogError(w, T("badges.api.no_permissions_edit_badge", "У вас нет прав на редактирование этого значка"), nil) return } @@ -570,7 +1142,7 @@ func (p *Plugin) dialogGrant(w http.ResponseWriter, r *http.Request, userID stri return } - if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) { + if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) { dialogError(w, T("badges.api.no_permissions_grant", "У вас нет прав на выдачу этого значка"), nil) return } @@ -593,6 +1165,10 @@ func (p *Plugin) dialogGrant(w http.ResponseWriter, r *http.Request, userID stri reason, _ := req.Submission[DialogFieldGrantReason].(string) shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeIDStr), grantToID, userID, reason) + if err == errAlreadyOwned { + dialogError(w, T("badges.error.already_owned", "Это достижение уже выдано этому пользователю"), nil) + return + } if err != nil { p.writeAPIError(w, &APIErrorResponse{ ID: "cannot grant badge", @@ -630,7 +1206,7 @@ func (p *Plugin) dialogCreateSubscription(w http.ResponseWriter, r *http.Request } T := p.getT(u.Locale) - if !canCreateSubscription(u, p.badgeAdminUserID, req.ChannelId) { + if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelId) { dialogError(w, T("badges.api.cannot_create_subscription", "Вы не можете создать подписку"), nil) return } @@ -670,7 +1246,7 @@ func (p *Plugin) dialogDeleteSubscription(w http.ResponseWriter, r *http.Request } T := p.getT(u.Locale) - if !canCreateSubscription(u, p.badgeAdminUserID, req.ChannelId) { + if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelId) { dialogError(w, T("badges.api.cannot_delete_subscription", "Вы не можете удалить подписку"), nil) return } @@ -762,7 +1338,7 @@ func (p *Plugin) grantBadge(w http.ResponseWriter, r *http.Request, pluginID str return } - if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) { + if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) { p.writeAPIError(w, &APIErrorResponse{ ID: "cannot grant badge", Message: "you have no permissions to grant this badge", @@ -772,6 +1348,12 @@ func (p *Plugin) grantBadge(w http.ResponseWriter, r *http.Request, pluginID str } shouldNotify, err := p.store.GrantBadge(req.BadgeID, req.UserID, req.BotID, req.Reason) + if err == errAlreadyOwned { + p.writeAPIError(w, &APIErrorResponse{ + ID: "already_owned", Message: "This badge is already owned by this user", StatusCode: http.StatusConflict, + }) + return + } if err != nil { p.writeAPIError(w, &APIErrorResponse{ ID: "cannot grant badge", @@ -975,17 +1557,33 @@ func (p *Plugin) getBadgeDetails(w http.ResponseWriter, r *http.Request, actingU p.mm.Log.Debug("Cannot get badge details", "badgeID", badgeID, "error", err) } - b, _ := json.Marshal(badge) + type BadgeDetailsResponse struct { + *badgesmodel.BadgeDetails + CanEdit bool `json:"can_edit"` + } + + resp := BadgeDetailsResponse{BadgeDetails: badge} + if badge != nil { + actingUser, userErr := p.mm.User.Get(actingUserID) + if userErr == nil { + bt, typeErr := p.store.GetType(badge.Type) + if typeErr == nil { + resp.CanEdit = canEditBadge(actingUser, p.badgeAdminUserIDs, &badge.Badge, bt) + } + } + } + + b, _ := json.Marshal(resp) _, _ = w.Write(b) } func (p *Plugin) getAllBadges(w http.ResponseWriter, r *http.Request, actingUserID string) { - badge, err := p.store.GetAllBadges() + badges, err := p.store.GetAllBadges() if err != nil { p.mm.Log.Debug("Cannot get all badges", "error", err) } - b, _ := json.Marshal(badge) + b, _ := json.Marshal(badges) _, _ = w.Write(b) } @@ -1073,3 +1671,264 @@ func (p *Plugin) getPluginURL() string { func (p *Plugin) getDialogURL() string { return p.getPluginURL() + DialogPath } + +func (p *Plugin) apiGrantBadge(w http.ResponseWriter, r *http.Request, userID string) { + var req GrantBadgeAPIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + req.BadgeID = strings.TrimSpace(req.BadgeID) + req.UserID = strings.TrimSpace(req.UserID) + + if req.BadgeID == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_badge_id", Message: "Badge ID is required", StatusCode: http.StatusBadRequest, + }) + return + } + + if req.UserID == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_user_id", Message: "User ID is required", StatusCode: http.StatusBadRequest, + }) + return + } + + badge, err := p.store.GetBadge(badgesmodel.BadgeID(req.BadgeID)) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "badge_not_found", Message: "Badge not found", StatusCode: http.StatusNotFound, + }) + return + } + + granter, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + badgeType, err := p.store.GetType(badge.Type) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "type_not_found", Message: "Badge type not found", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission_grant", Message: "No permission to grant this badge", StatusCode: http.StatusForbidden, + }) + return + } + + grantToUser, err := p.mm.User.Get(req.UserID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "user_not_found", Message: "User not found", StatusCode: http.StatusNotFound, + }) + return + } + + shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(req.BadgeID), req.UserID, userID, req.Reason) + if errors.Is(err, errAlreadyOwned) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "already_owned", Message: "This badge is already owned by this user", StatusCode: http.StatusConflict, + }) + return + } + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_grant_badge", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + if shouldNotify { + channelID := req.ChannelID + p.notifyGrant(badgesmodel.BadgeID(req.BadgeID), userID, grantToUser, req.NotifyHere, channelID, req.Reason) + } + + resp := map[string]string{"status": "ok"} + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} + +func (p *Plugin) apiCreateSubscription(w http.ResponseWriter, r *http.Request, userID string) { + var req SubscriptionAPIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + u, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelID) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission_subscription", Message: "No permission to manage subscriptions", StatusCode: http.StatusForbidden, + }) + return + } + + req.TypeID = strings.TrimSpace(req.TypeID) + if req.TypeID == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_type_id", Message: "Type ID is required", StatusCode: http.StatusBadRequest, + }) + return + } + + err = p.store.AddSubscription(badgesmodel.BadgeType(req.TypeID), req.ChannelID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_create_subscription", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + T := p.getT(u.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.subscription_added", "Подписка добавлена"), + }) + + resp := map[string]string{"status": "ok"} + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} + +func (p *Plugin) apiDeleteSubscription(w http.ResponseWriter, r *http.Request, userID string) { + var req SubscriptionAPIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + u, err := p.mm.User.Get(userID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_user", Message: "Cannot get user", StatusCode: http.StatusInternalServerError, + }) + return + } + + if !canCreateSubscription(u, p.badgeAdminUserIDs, req.ChannelID) { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission_subscription", Message: "No permission to manage subscriptions", StatusCode: http.StatusForbidden, + }) + return + } + + req.TypeID = strings.TrimSpace(req.TypeID) + if req.TypeID == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_type_id", Message: "Type ID is required", StatusCode: http.StatusBadRequest, + }) + return + } + + err = p.store.RemoveSubscriptions(badgesmodel.BadgeType(req.TypeID), req.ChannelID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_delete_subscription", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + T := p.getT(u.Locale) + p.mm.Post.SendEphemeralPost(userID, &model.Post{ + UserId: p.BotUserID, + ChannelId: req.ChannelID, + Message: T("badges.api.subscription_removed", "Подписка удалена"), + }) + + resp := map[string]string{"status": "ok"} + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} + +func (p *Plugin) apiGetChannelSubscriptions(w http.ResponseWriter, r *http.Request, userID string) { + channelID := mux.Vars(r)["channelID"] + if channelID == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Channel ID is required", StatusCode: http.StatusBadRequest, + }) + return + } + + types, err := p.store.GetChannelSubscriptions(channelID) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_get_types", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + b, _ := json.Marshal(types) + _, _ = w.Write(b) +} + +func (p *Plugin) apiRevokeOwnership(w http.ResponseWriter, r *http.Request, userID string) { + var req RevokeOwnershipRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "Invalid request body", StatusCode: http.StatusBadRequest, + }) + return + } + + req.BadgeID = strings.TrimSpace(req.BadgeID) + req.UserID = strings.TrimSpace(req.UserID) + req.Time = strings.TrimSpace(req.Time) + if req.BadgeID == "" || req.UserID == "" || req.Time == "" { + p.writeAPIError(w, &APIErrorResponse{ + ID: "invalid_request", Message: "badge_id, user_id and time are required", StatusCode: http.StatusBadRequest, + }) + return + } + + ownership, err := p.store.FindOwnership(badgesmodel.BadgeID(req.BadgeID), req.UserID, req.Time) + if err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "ownership_not_found", Message: "Ownership not found", StatusCode: http.StatusNotFound, + }) + return + } + + isAdmin := p.badgeAdminUserIDs[userID] + if ownership.GrantedBy != userID && !isAdmin { + p.writeAPIError(w, &APIErrorResponse{ + ID: "no_permission_revoke", Message: "No permission to revoke this ownership", StatusCode: http.StatusForbidden, + }) + return + } + + if err := p.store.RevokeOwnership(badgesmodel.BadgeID(req.BadgeID), req.UserID, req.Time); err != nil { + p.writeAPIError(w, &APIErrorResponse{ + ID: "cannot_revoke", Message: err.Error(), StatusCode: http.StatusInternalServerError, + }) + return + } + + resp := map[string]string{"status": "ok"} + b, _ := json.Marshal(resp) + _, _ = w.Write(b) +} diff --git a/server/command.go b/server/command.go index ce671d6..d0c312a 100644 --- a/server/command.go +++ b/server/command.go @@ -271,7 +271,12 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m return commandError(err.Error()) } - if !canEditBadge(u, p.badgeAdminUserID, badge) { + badgeType, err := p.store.GetType(badge.Type) + if err != nil { + return commandError(err.Error()) + } + + if !canEditBadge(u, p.badgeAdminUserIDs, badge, badgeType) { return commandError(T("badges.error.cannot_edit_badge", "У вас нет прав на редактирование этого значка")) } @@ -359,7 +364,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo } T := p.getT(u.Locale) - if !canCreateType(u, p.badgeAdminUserID, false) { + if !canCreateType(u, p.badgeAdminUserIDs, false) { return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков.")) } @@ -379,7 +384,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo return commandError(err.Error()) } - if !canEditType(u, p.badgeAdminUserID, typeDefinition) { + if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) { return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа")) } @@ -493,7 +498,7 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, * } T := p.getT(u.Locale) - if !canCreateType(u, p.badgeAdminUserID, false) { + if !canCreateType(u, p.badgeAdminUserIDs, false) { return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков.")) } @@ -582,7 +587,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model return commandError(err.Error()) } - if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) { + if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) { return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка")) } @@ -592,6 +597,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model } shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "") + if err == errAlreadyOwned { + return commandError(T("badges.error.already_owned", "Это достижение уже выдано этому пользователю")) + } if err != nil { return commandError(err.Error()) } @@ -755,7 +763,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs) } T := p.getT(actingUser.Locale) - if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) { + if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) { return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки")) } @@ -818,7 +826,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs) } T := p.getT(actingUser.Locale) - if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) { + if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) { return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки")) } diff --git a/server/configuration.go b/server/configuration.go index e93001b..5f2486f 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -2,6 +2,7 @@ package main import ( "reflect" + "strings" "github.com/pkg/errors" ) @@ -78,13 +79,20 @@ func (p *Plugin) OnConfigurationChange() error { return errors.Wrap(err, "failed to load plugin configuration") } - p.badgeAdminUserID = "" + p.badgeAdminUserIDs = make(map[string]bool) if configuration.BadgesAdmin != "" { - u, err := p.API.GetUserByUsername(configuration.BadgesAdmin) - if err != nil { - return errors.Wrap(err, "cannot get badge admin user") + for username := range strings.SplitSeq(configuration.BadgesAdmin, ",") { + username = strings.TrimSpace(username) + if username == "" { + continue + } + u, err := p.API.GetUserByUsername(username) + if err != nil { + p.API.LogWarn("Cannot find badge admin user", "username", username, "error", err.Error()) + continue + } + p.badgeAdminUserIDs[u.Id] = true } - p.badgeAdminUserID = u.Id } p.setConfiguration(configuration) diff --git a/server/i18n/en.json b/server/i18n/en.json index ca865a3..3510fd8 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1,21 +1,21 @@ [ - {"id": "badges.dialog.create_badge.title", "translation": "Create badge"}, + {"id": "badges.dialog.create_badge.title", "translation": "Create achievement"}, {"id": "badges.dialog.create_badge.submit", "translation": "Create"}, - {"id": "badges.dialog.edit_badge.title", "translation": "Edit badge"}, + {"id": "badges.dialog.edit_badge.title", "translation": "Edit achievement"}, {"id": "badges.dialog.edit_badge.submit", "translation": "Save"}, {"id": "badges.dialog.create_type.title", "translation": "Create type"}, {"id": "badges.dialog.create_type.submit", "translation": "Create"}, {"id": "badges.dialog.edit_type.title", "translation": "Edit type"}, {"id": "badges.dialog.edit_type.submit", "translation": "Save"}, - {"id": "badges.dialog.grant.title", "translation": "Grant badge"}, + {"id": "badges.dialog.grant.title", "translation": "Grant achievement"}, {"id": "badges.dialog.grant.submit", "translation": "Grant"}, - {"id": "badges.dialog.grant.intro", "translation": "Grant badge to @%s"}, + {"id": "badges.dialog.grant.intro", "translation": "Grant achievement to @%s"}, {"id": "badges.dialog.create_subscription.title", "translation": "Create subscription"}, {"id": "badges.dialog.create_subscription.submit", "translation": "Add"}, - {"id": "badges.dialog.create_subscription.intro", "translation": "Select the badge type you want to subscribe to this channel."}, + {"id": "badges.dialog.create_subscription.intro", "translation": "Select the achievement type you want to subscribe to this channel."}, {"id": "badges.dialog.delete_subscription.title", "translation": "Delete subscription"}, {"id": "badges.dialog.delete_subscription.submit", "translation": "Remove"}, - {"id": "badges.dialog.delete_subscription.intro", "translation": "Select the badge type you want to unsubscribe from this channel."}, + {"id": "badges.dialog.delete_subscription.intro", "translation": "Select the achievement type you want to unsubscribe from this channel."}, {"id": "badges.field.name", "translation": "Name"}, {"id": "badges.field.description", "translation": "Description"}, @@ -23,45 +23,46 @@ {"id": "badges.field.image.help", "translation": "Enter an emoticon name"}, {"id": "badges.field.type", "translation": "Type"}, {"id": "badges.field.multiple", "translation": "Multiple"}, - {"id": "badges.field.multiple.help", "translation": "Whether the badge can be granted multiple times"}, - {"id": "badges.field.delete_badge", "translation": "Delete badge"}, - {"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this badge permanently."}, - {"id": "badges.field.everyone_can_create", "translation": "Everyone can create badge"}, - {"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create a badge of this type"}, + {"id": "badges.field.multiple.help", "translation": "Whether the achievement can be granted multiple times"}, + {"id": "badges.field.delete_badge", "translation": "Delete achievement"}, + {"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this achievement permanently."}, + {"id": "badges.field.everyone_can_create", "translation": "Everyone can create achievement"}, + {"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create an achievement of this type"}, {"id": "badges.field.allowlist_create", "translation": "Can create allowlist"}, - {"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create badges of this type."}, - {"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant badge"}, - {"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant a badge of this type"}, + {"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create achievements of this type."}, + {"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant achievement"}, + {"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant an achievement of this type"}, {"id": "badges.field.allowlist_grant", "translation": "Can grant allowlist"}, - {"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant badges of this type."}, + {"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant achievements of this type."}, {"id": "badges.field.delete_type", "translation": "Remove type"}, - {"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated badges permanently."}, + {"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated achievements permanently."}, {"id": "badges.field.user", "translation": "User"}, - {"id": "badges.field.badge", "translation": "Badge"}, + {"id": "badges.field.badge", "translation": "Achievement"}, {"id": "badges.field.reason", "translation": "Reason"}, - {"id": "badges.field.reason.help", "translation": "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."}, + {"id": "badges.field.reason.help", "translation": "Reason why you are granting this achievement. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."}, {"id": "badges.field.notify_here", "translation": "Notify on this channel"}, - {"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person."}, + {"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this achievement to this person."}, {"id": "badges.error.unknown", "translation": "An unknown error occurred. Please talk to your system administrator for help."}, {"id": "badges.error.cannot_get_user", "translation": "Cannot get user."}, - {"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the badges database."}, + {"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the achievements database."}, {"id": "badges.error.specify_create", "translation": "Specify what you want to create."}, - {"id": "badges.error.create_badge_or_type", "translation": "You can create either badge or type"}, - {"id": "badges.error.no_types_available", "translation": "You cannot create badges from any type."}, - {"id": "badges.error.must_set_badge_id", "translation": "You must set the badge ID"}, - {"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this badge"}, + {"id": "badges.error.create_badge_or_type", "translation": "You can create either achievement or type"}, + {"id": "badges.error.no_types_available", "translation": "You cannot create achievements from any type."}, + {"id": "badges.error.must_set_badge_id", "translation": "You must set the achievement ID"}, + {"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this achievement"}, {"id": "badges.error.specify_edit", "translation": "Specify what you want to edit."}, - {"id": "badges.error.edit_badge_or_type", "translation": "You can edit either badge or type"}, - {"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit a badge type."}, + {"id": "badges.error.edit_badge_or_type", "translation": "You can edit either achievement or type"}, + {"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit an achievement type."}, {"id": "badges.error.must_provide_type_id", "translation": "You must provide a type id"}, {"id": "badges.error.cannot_edit_type", "translation": "You cannot edit this type"}, - {"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this badge"}, - {"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that badge"}, + {"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this achievement"}, + {"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that achievement"}, {"id": "badges.error.specify_subscription", "translation": "Specify what you want to do."}, {"id": "badges.error.create_or_delete_subscription", "translation": "You can either create or delete subscriptions"}, {"id": "badges.error.cannot_create_subscription", "translation": "You cannot create subscriptions"}, - {"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create a badge type."}, + {"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create an achievement type."}, + {"id": "badges.error.already_owned", "translation": "This achievement is already owned by this user"}, {"id": "badges.success.clean", "translation": "Clean"}, {"id": "badges.success.granted", "translation": "Granted"}, @@ -72,8 +73,8 @@ {"id": "badges.api.empty_emoji", "translation": "Empty emoji"}, {"id": "badges.api.invalid_field", "translation": "Invalid field"}, {"id": "badges.api.type_not_exist", "translation": "This type does not exist"}, - {"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this badge"}, - {"id": "badges.api.badge_created", "translation": "Badge `%s` created."}, + {"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this achievement"}, + {"id": "badges.api.badge_created", "translation": "Achievement `%s` created."}, {"id": "badges.api.no_permissions_create_type", "translation": "You have no permissions to create a type"}, {"id": "badges.api.cannot_find_user", "translation": "Cannot find user"}, {"id": "badges.api.error_getting_user", "translation": "Error getting user %s: %v"}, @@ -83,24 +84,25 @@ {"id": "badges.api.could_not_get_type", "translation": "Could not get the type"}, {"id": "badges.api.no_permissions_edit_type", "translation": "You have no permissions to edit this type"}, {"id": "badges.api.type_updated", "translation": "Type `%s` updated."}, - {"id": "badges.api.cannot_get_badge", "translation": "Cannot get badge"}, - {"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this badge"}, - {"id": "badges.api.could_not_get_badge", "translation": "Could not get the badge"}, - {"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this badge"}, - {"id": "badges.api.badge_updated", "translation": "Badge `%s` updated."}, - {"id": "badges.api.badge_not_found", "translation": "Badge not found"}, - {"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this badge"}, + {"id": "badges.api.cannot_get_badge", "translation": "Cannot get achievement"}, + {"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this achievement"}, + {"id": "badges.api.could_not_get_badge", "translation": "Could not get the achievement"}, + {"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this achievement"}, + {"id": "badges.api.badge_updated", "translation": "Achievement `%s` updated."}, + {"id": "badges.api.badge_not_found", "translation": "Achievement not found"}, + {"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this achievement"}, {"id": "badges.api.user_not_found", "translation": "User not found"}, - {"id": "badges.api.badge_granted", "translation": "Badge `%s` granted to @%s."}, + {"id": "badges.api.badge_granted", "translation": "Achievement `%s` granted to @%s."}, {"id": "badges.api.cannot_create_subscription", "translation": "You cannot create a subscription"}, {"id": "badges.api.subscription_added", "translation": "Subscription added"}, {"id": "badges.api.cannot_delete_subscription", "translation": "You cannot delete a subscription"}, {"id": "badges.api.subscription_removed", "translation": "Subscription removed"}, + {"id": "badges.api.cannot_delete_default_type", "translation": "Cannot delete the default type"}, {"id": "badges.api.not_authorized", "translation": "Not authorized"}, - {"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` badge."}, + {"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` achievement."}, {"id": "badges.notify.dm_reason", "translation": "\nWhy? "}, - {"id": "badges.notify.title", "translation": "%sbadge granted!"}, - {"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` badge."}, + {"id": "badges.notify.title", "translation": "%sachievement granted!"}, + {"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` achievement."}, {"id": "badges.notify.no_permission_channel", "translation": "You don't have permissions to notify the grant on this channel."} -] +] \ No newline at end of file diff --git a/server/i18n/i18n.go b/server/i18n/i18n.go index 0ab687f..4231801 100644 --- a/server/i18n/i18n.go +++ b/server/i18n/i18n.go @@ -18,6 +18,7 @@ type Bundle i18n.Bundle func Init() *Bundle { bundle := i18n.NewBundle(language.Russian) _, _ = bundle.LoadMessageFileFS(i18nFiles, "en.json") + _, _ = bundle.LoadMessageFileFS(i18nFiles, "ru.json") return (*Bundle)(bundle) } diff --git a/server/i18n/ru.json b/server/i18n/ru.json index 0c2e503..7b6778c 100644 --- a/server/i18n/ru.json +++ b/server/i18n/ru.json @@ -1,21 +1,21 @@ [ - {"id": "badges.dialog.create_badge.title", "translation": "Создать значок"}, + {"id": "badges.dialog.create_badge.title", "translation": "Создать достижение"}, {"id": "badges.dialog.create_badge.submit", "translation": "Создать"}, - {"id": "badges.dialog.edit_badge.title", "translation": "Редактировать значок"}, + {"id": "badges.dialog.edit_badge.title", "translation": "Редактировать достижение"}, {"id": "badges.dialog.edit_badge.submit", "translation": "Сохранить"}, {"id": "badges.dialog.create_type.title", "translation": "Создать тип"}, {"id": "badges.dialog.create_type.submit", "translation": "Создать"}, {"id": "badges.dialog.edit_type.title", "translation": "Редактировать тип"}, {"id": "badges.dialog.edit_type.submit", "translation": "Сохранить"}, - {"id": "badges.dialog.grant.title", "translation": "Выдать значок"}, + {"id": "badges.dialog.grant.title", "translation": "Выдать достижение"}, {"id": "badges.dialog.grant.submit", "translation": "Выдать"}, - {"id": "badges.dialog.grant.intro", "translation": "Выдать значок пользователю @%s"}, + {"id": "badges.dialog.grant.intro", "translation": "Выдать достижение пользователю @%s"}, {"id": "badges.dialog.create_subscription.title", "translation": "Создать подписку"}, {"id": "badges.dialog.create_subscription.submit", "translation": "Добавить"}, - {"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип значка, на который хотите подписать этот канал."}, + {"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип достижения, на который хотите подписать этот канал."}, {"id": "badges.dialog.delete_subscription.title", "translation": "Удалить подписку"}, {"id": "badges.dialog.delete_subscription.submit", "translation": "Удалить"}, - {"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип значка, подписку на который хотите удалить из этого канала."}, + {"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."}, {"id": "badges.field.name", "translation": "Название"}, {"id": "badges.field.description", "translation": "Описание"}, @@ -23,45 +23,46 @@ {"id": "badges.field.image.help", "translation": "Введите название эмодзи"}, {"id": "badges.field.type", "translation": "Тип"}, {"id": "badges.field.multiple", "translation": "Многократный"}, - {"id": "badges.field.multiple.help", "translation": "Можно ли выдавать этот значок несколько раз"}, - {"id": "badges.field.delete_badge", "translation": "Удалить значок"}, - {"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, значок будет удалён безвозвратно."}, - {"id": "badges.field.everyone_can_create", "translation": "Все могут создавать значки"}, - {"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать значок этого типа"}, + {"id": "badges.field.multiple.help", "translation": "Можно ли выдавать это достижение несколько раз"}, + {"id": "badges.field.delete_badge", "translation": "Удалить достижение"}, + {"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, достижение будет удалён безвозвратно."}, + {"id": "badges.field.everyone_can_create", "translation": "Все могут создавать достижения"}, + {"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать достижение этого типа"}, {"id": "badges.field.allowlist_create", "translation": "Список допущенных к созданию"}, - {"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."}, - {"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать значки"}, - {"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать значок этого типа"}, + {"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать достижения этого типа."}, + {"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать достижения"}, + {"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать достижение этого типа"}, {"id": "badges.field.allowlist_grant", "translation": "Список допущенных к выдаче"}, - {"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."}, + {"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."}, {"id": "badges.field.delete_type", "translation": "Удалить тип"}, - {"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."}, + {"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."}, {"id": "badges.field.user", "translation": "Пользователь"}, - {"id": "badges.field.badge", "translation": "Значок"}, + {"id": "badges.field.badge", "translation": "Достижение"}, {"id": "badges.field.reason", "translation": "Причина"}, - {"id": "badges.field.reason.help", "translation": "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."}, + {"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."}, {"id": "badges.field.notify_here", "translation": "Уведомить в этом канале"}, - {"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали значок этому пользователю."}, + {"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали достижение этому пользователю."}, {"id": "badges.error.unknown", "translation": "Произошла неизвестная ошибка. Обратитесь к системному администратору."}, {"id": "badges.error.cannot_get_user", "translation": "Не удалось получить пользователя."}, - {"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу значков."}, + {"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу достижений."}, {"id": "badges.error.specify_create", "translation": "Укажите, что вы хотите создать."}, {"id": "badges.error.create_badge_or_type", "translation": "Можно создать badge или type"}, - {"id": "badges.error.no_types_available", "translation": "Вы не можете создать значки ни одного типа."}, - {"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID значка"}, - {"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого значка"}, + {"id": "badges.error.no_types_available", "translation": "Вы не можете создать достижения ни одного типа."}, + {"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID достижения"}, + {"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"}, {"id": "badges.error.specify_edit", "translation": "Укажите, что вы хотите отредактировать."}, {"id": "badges.error.edit_badge_or_type", "translation": "Можно редактировать badge или type"}, - {"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа значков."}, + {"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа достижений."}, {"id": "badges.error.must_provide_type_id", "translation": "Необходимо указать ID типа"}, {"id": "badges.error.cannot_edit_type", "translation": "У вас нет прав на редактирование этого типа"}, - {"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"}, - {"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать этот значок"}, + {"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"}, + {"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать это достижение"}, {"id": "badges.error.specify_subscription", "translation": "Укажите, что вы хотите сделать."}, {"id": "badges.error.create_or_delete_subscription", "translation": "Можно создать или удалить подписку"}, {"id": "badges.error.cannot_create_subscription", "translation": "Вы не можете создавать подписки"}, - {"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа значков."}, + {"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа достижений."}, + {"id": "badges.error.already_owned", "translation": "Это достижение уже выдано этому пользователю"}, {"id": "badges.success.clean", "translation": "Очищено"}, {"id": "badges.success.granted", "translation": "Выдано"}, @@ -72,8 +73,8 @@ {"id": "badges.api.empty_emoji", "translation": "Пустой эмодзи"}, {"id": "badges.api.invalid_field", "translation": "Некорректное поле"}, {"id": "badges.api.type_not_exist", "translation": "Этот тип не существует"}, - {"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого значка"}, - {"id": "badges.api.badge_created", "translation": "Значок `%s` создан."}, + {"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого достижения"}, + {"id": "badges.api.badge_created", "translation": "Достижение `%s` создано."}, {"id": "badges.api.no_permissions_create_type", "translation": "У вас нет прав на создание типа"}, {"id": "badges.api.cannot_find_user", "translation": "Не удалось найти пользователя"}, {"id": "badges.api.error_getting_user", "translation": "Ошибка получения пользователя %s: %v"}, @@ -83,24 +84,25 @@ {"id": "badges.api.could_not_get_type", "translation": "Не удалось получить тип"}, {"id": "badges.api.no_permissions_edit_type", "translation": "У вас нет прав на редактирование этого типа"}, {"id": "badges.api.type_updated", "translation": "Тип `%s` обновлён."}, - {"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить значок"}, - {"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать этот значок"}, - {"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить значок"}, - {"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого значка"}, - {"id": "badges.api.badge_updated", "translation": "Значок `%s` обновлён."}, - {"id": "badges.api.badge_not_found", "translation": "Значок не найден"}, - {"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"}, + {"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить достижение"}, + {"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать это достижение"}, + {"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить достижение"}, + {"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"}, + {"id": "badges.api.badge_updated", "translation": "Достижение `%s` обновлёно."}, + {"id": "badges.api.badge_not_found", "translation": "Достижение не найдено"}, + {"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"}, {"id": "badges.api.user_not_found", "translation": "Пользователь не найден"}, - {"id": "badges.api.badge_granted", "translation": "Значок `%s` выдан @%s."}, + {"id": "badges.api.badge_granted", "translation": "Достижение `%s` выдан @%s."}, {"id": "badges.api.cannot_create_subscription", "translation": "Вы не можете создать подписку"}, {"id": "badges.api.subscription_added", "translation": "Подписка добавлена"}, {"id": "badges.api.cannot_delete_subscription", "translation": "Вы не можете удалить подписку"}, {"id": "badges.api.subscription_removed", "translation": "Подписка удалена"}, + {"id": "badges.api.cannot_delete_default_type", "translation": "Нельзя удалить тип по умолчанию"}, {"id": "badges.api.not_authorized", "translation": "Не авторизован"}, - {"id": "badges.notify.dm_text", "translation": "@%s выдал вам значок %s`%s`."}, + {"id": "badges.notify.dm_text", "translation": "@%s выдал вам достижение %s`%s`."}, {"id": "badges.notify.dm_reason", "translation": "\nПочему? "}, - {"id": "badges.notify.title", "translation": "%sзначок выдан!"}, - {"id": "badges.notify.channel_text", "translation": "@%s выдал @%s значок %s`%s`."}, + {"id": "badges.notify.title", "translation": "%sдостижение выдано!"}, + {"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."}, {"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."} ] diff --git a/server/plugin.go b/server/plugin.go index 22d5c44..cc8c852 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -28,7 +28,7 @@ type Plugin struct { BotUserID string store Store router *mux.Router - badgeAdminUserID string + badgeAdminUserIDs map[string]bool i18nBundle *i18n.Bundle } @@ -57,6 +57,9 @@ func (p *Plugin) OnActivate() error { } p.BotUserID = botID p.store = NewStore(p.API) + if err := p.store.EnsureDefaultType(p.BotUserID); err != nil { + p.mm.Log.Warn("Failed to ensure default type", "error", err.Error()) + } p.i18nBundle = i18n.Init() p.initializeAPI() diff --git a/server/store.go b/server/store.go index 5a9be48..a8ad05b 100644 --- a/server/store.go +++ b/server/store.go @@ -12,6 +12,7 @@ import ( var errInvalidBadge = errors.New("invalid badge") var errBadgeNotFound = errors.New("badge not found") +var errAlreadyOwned = errors.New("already owned") type Store interface { // Interface @@ -33,12 +34,17 @@ type Store interface { UpdateBadge(b *badgesmodel.Badge) error DeleteType(tID badgesmodel.BadgeType) error DeleteBadge(bID badgesmodel.BadgeID) error + RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error + FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error) AddSubscription(tID badgesmodel.BadgeType, cID string) error RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error) GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error) + // Default type + EnsureDefaultType(botID string) error + // PAPI EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error) } @@ -144,6 +150,28 @@ func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*bad return t, nil } +func (s *store) EnsureDefaultType(botID string) error { + types, _, err := s.getAllTypes() + if err != nil { + return err + } + + for _, t := range types { + if t.IsDefault { + return nil + } + } + + _, err = s.addType(&badgesmodel.BadgeTypeDefinition{ + Name: badgesmodel.DefaultTypeName, + IsDefault: true, + CreatedBy: botID, + CanCreate: badgesmodel.PermissionScheme{Everyone: true}, + CanGrant: badgesmodel.PermissionScheme{Everyone: true}, + }, false) + return err +} + func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) { badges, _, err := s.getAllBadges() if err != nil { @@ -417,6 +445,11 @@ func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) { } func (s *store) DeleteType(tID badgesmodel.BadgeType) error { + t, err := s.GetType(tID) + if err == nil && t.IsDefault { + return errors.New("cannot delete default type") + } + s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) }) bb, _, err := s.getAllBadges() @@ -473,6 +506,25 @@ func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error { return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) }) } +func (s *store) FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error) { + ownership, _, err := s.getOwnershipList() + if err != nil { + return nil, err + } + + for _, o := range ownership { + if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime { + return &o, nil + } + } + + return nil, errors.New("ownership not found") +} + +func (s *store) RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error { + return s.doAtomic(func() (bool, error) { return s.atomicRevokeOwnership(badgeID, userID, grantTime) }) +} + func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error { toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID} return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) }) diff --git a/server/store_atomic.go b/server/store_atomic.go index a09ace8..c33f2c1 100644 --- a/server/store_atomic.go +++ b/server/store_atomic.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + "time" "github.com/larkox/mattermost-plugin-badges/badgesmodel" ) @@ -107,7 +108,7 @@ func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bo } if !isMultiple && ownership.IsOwned(o.User, o.Badge) { - return false, true, nil + return false, true, errAlreadyOwned } ownership = append(ownership, o) @@ -159,6 +160,28 @@ func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) { return s.compareAndSet(KVKeyBadges, data, bb) } +func (s *store) atomicRevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (bool, error) { + ownership, data, err := s.getOwnershipList() + if err != nil { + return false, err + } + + found := false + for i, o := range ownership { + if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime { + ownership = append(ownership[:i], ownership[i+1:]...) + found = true + break + } + } + + if !found { + return true, nil + } + + return s.compareAndSet(KVKeyOwnership, data, ownership) +} + func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) { subs, data, err := s.getAllSubscriptions() if err != nil { diff --git a/server/suggestions.go b/server/suggestions.go index da0c41e..5715f6f 100644 --- a/server/suggestions.go +++ b/server/suggestions.go @@ -23,7 +23,7 @@ func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, erro p.mm.Log.Debug("Badge with missing type", "badge", b) continue } - if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) { + if canGrantBadge(user, p.badgeAdminUserIDs, b, badgeType) { out = append(out, b) } } @@ -39,7 +39,7 @@ func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeType out := badgesmodel.BadgeTypeList{} for _, t := range types { - if canCreateBadge(user, p.badgeAdminUserID, t) { + if canCreateBadge(user, p.badgeAdminUserIDs, t) { out = append(out, t) } } @@ -55,7 +55,7 @@ func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, e out := badgesmodel.BadgeTypeList{} for _, t := range types { - if canEditType(user, p.badgeAdminUserID, t) { + if canEditType(user, p.badgeAdminUserIDs, t) { out = append(out, t) } } @@ -69,9 +69,18 @@ func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error return nil, err } + typeCache := map[badgesmodel.BadgeType]*badgesmodel.BadgeTypeDefinition{} out := []*badgesmodel.Badge{} for _, b := range bb { - if canEditBadge(user, p.badgeAdminUserID, b) { + bt, ok := typeCache[b.Type] + if !ok { + bt, err = p.store.GetType(b.Type) + if err != nil { + continue + } + typeCache[b.Type] = bt + } + if canEditBadge(user, p.badgeAdminUserIDs, b, bt) { out = append(out, b) } } diff --git a/server/utils.go b/server/utils.go index 49ae72e..11bd65b 100644 --- a/server/utils.go +++ b/server/utils.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "strings" "github.com/larkox/mattermost-plugin-badges/badgesmodel" "github.com/mattermost/mattermost-server/v5/model" @@ -23,8 +24,8 @@ func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool { return false } -func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool { - if badgeAdminID != "" && user.Id == badgeAdminID { +func canGrantBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool { + if badgeAdminIDs[user.Id] { return true } @@ -57,8 +58,8 @@ func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Bad return badgeType.CanGrant.Everyone } -func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool { - if badgeAdminID != "" && user.Id == badgeAdminID { +func canCreateBadge(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool { + if badgeAdminIDs[user.Id] { return true } @@ -87,36 +88,44 @@ func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmode return badgeType.CanCreate.Everyone } -func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool { - if badgeAdminID != "" && user.Id == badgeAdminID { +func canEditType(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool { + if badgeAdminIDs[user.Id] { return true } return user.IsSystemAdmin() } -func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool { - if badgeAdminID != "" && user.Id == badgeAdminID { +func canEditBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool { + if badgeAdminIDs[user.Id] { return true } - return user.IsSystemAdmin() || user.Id == badge.CreatedBy + if user.IsSystemAdmin() { + return true + } + + if badgeType != nil && canCreateBadge(user, badgeAdminIDs, badgeType) { + return true + } + + return false } -func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool { +func canCreateType(user *model.User, badgeAdminIDs map[string]bool, isPlugin bool) bool { if isPlugin { return true } - if badgeAdminID != "" && user.Id == badgeAdminID { + if badgeAdminIDs[user.Id] { return true } return user.IsSystemAdmin() } -func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool { - if badgeAdminID != "" && user.Id == badgeAdminID { +func canCreateSubscription(user *model.User, badgeAdminIDs map[string]bool, channelID string) bool { + if badgeAdminIDs[user.Id] { return true } @@ -160,8 +169,9 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason } dmAttachment := model.SlackAttachment{ - Title: Tdm("badges.notify.title", "%sзначок выдан!", image), - Text: dmText, + Fallback: dmText, + Title: Tdm("badges.notify.title", "%sзначок выдан!", image), + Text: dmText, } model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment}) err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost) @@ -192,7 +202,14 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante p.mm.Log.Debug("notify subscription error", "err", err) } } - if inChannel { + alreadyNotified := false + for _, sub := range subs { + if sub == channelID { + alreadyNotified = true + break + } + } + if inChannel && !alreadyNotified { if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) { Tg := p.getT(granterUser.Locale) p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID}) @@ -208,6 +225,40 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante } } +// resolveUsernameList parses a comma-separated list of usernames and returns a map of user IDs. +func (p *Plugin) resolveUsernameList(csv string) (map[string]bool, error) { + result := map[string]bool{} + usernames := strings.Split(csv, ",") + for _, username := range usernames { + username = strings.TrimSpace(username) + if username == "" { + continue + } + user, err := p.mm.User.GetByUsername(username) + if err != nil { + return nil, fmt.Errorf("user not found: %s", username) + } + result[user.Id] = true + } + return result, nil +} + +// resolveUserIDList converts a map of user IDs to a comma-separated list of usernames. +func (p *Plugin) resolveUserIDList(ids map[string]bool) string { + var names []string + for id, allowed := range ids { + if !allowed { + continue + } + user, err := p.mm.User.Get(id) + if err != nil { + continue + } + names = append(names, user.Username) + } + return strings.Join(names, ", ") +} + func getBooleanString(in bool) string { if in { return TrueString diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index e3ebef5..253c25d 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -194,22 +194,14 @@ "skipComments": false } ], - "max-nested-callbacks": [ - 2, - { - "max": 2 - } - ], + "max-nested-callbacks": 0, "max-statements-per-line": [ 2, { "max": 1 } ], - "multiline-ternary": [ - 1, - "never" - ], + "multiline-ternary": 0, "new-cap": 2, "new-parens": 2, "newline-before-return": 0, @@ -415,10 +407,7 @@ 2, "always" ], - "operator-linebreak": [ - 2, - "after" - ], + "operator-linebreak": 0, "padded-blocks": [ 2, "never" @@ -697,7 +686,8 @@ { "extensions": [".jsx", ".tsx"] } - ] + ], + "react/prop-types": 0 } } ] diff --git a/webapp/.yarn/install-state.gz b/webapp/.yarn/install-state.gz deleted file mode 100644 index ad7a468..0000000 Binary files a/webapp/.yarn/install-state.gz and /dev/null differ diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8c898c5..fbd7b0c 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,40 +1,181 @@ { "badges.loading": "Loading...", - "badges.no_badges_yet": "No badges yet.", - "badges.badge_not_found": "Badge not found.", + "badges.no_badges_yet": "No achievements yet.", + "badges.empty.title": "No achievements yet", + "badges.empty.description": "Create your first achievement to recognize contributions of your team members.", + "badges.badge_not_found": "Achievement not found.", "badges.user_not_found": "User not found.", "badges.unknown": "unknown", - "badges.rhs.all_badges": "All badges", - "badges.rhs.my_badges": "My badges", - "badges.rhs.user_badges": "@{username}'s badges", - "badges.rhs.badge_details": "Badge Details", + "badges.rhs.all_badges": "All achievements", + "badges.rhs.my_badges": "My achievements", + "badges.rhs.user_badges": "@{username}'s achievements", + "badges.rhs.badge_details": "Achievement Details", + "badges.label.name": "Name:", + "badges.label.description": "Description:", "badges.label.type": "Type: {typeName}", "badges.label.created_by": "Created by: {username}", "badges.label.granted_by": "Granted by: {username}", "badges.label.granted_at": "Granted at: {date}", "badges.label.reason": "Why? {reason}", + "badges.label.count": "Count: {count}", "badges.granted.not_yet": "Not yet granted.", "badges.granted.multiple": "Granted {times, plural, one {# time} other {# times}} to {users, plural, one {# user} other {# users}}.", "badges.granted.single": "Granted to {users, plural, one {# user} other {# users}}.", "badges.granted_to": "Granted to:", + "badges.not_granted_yet": "Not granted to anyone yet", - "badges.set_status": "Set status to this badge", - "badges.grant_badge": "Grant badge", + "badges.set_status": "Set status to this achievement", + "badges.grant_badge": "Grant achievement", "badges.and_more": "and {count} more. Click to see all.", - "badges.menu.open_list": "Open the list of all badges.", - "badges.menu.create_badge": "Create badge", - "badges.menu.create_type": "Create badge type", - "badges.menu.add_subscription": "Add badge subscription", - "badges.menu.remove_subscription": "Remove badge subscription", + "badges.menu.open_list": "Achievements.", + "badges.menu.create_badge": "Create achievement", + "badges.menu.create_type": "Create achievement type", + "badges.menu.add_subscription": "Add achievement subscription", + "badges.menu.remove_subscription": "Remove achievement subscription", - "badges.sidebar.title": "Badges", - "badges.popover.title": "Badges", + "badges.sidebar.title": "Achievements", + "badges.popover.title": "Achievements", - "badges.admin.label": "Achievements Admin:", - "badges.admin.placeholder": "username", - "badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges." + "badges.admin.label": "Achievements Administrators:", + "badges.admin.placeholder": "Start typing a name...", + "badges.admin.help_text": "These users will be considered achievements plugin administrators. They can create types, as well as modify and grant any achievements.", + "badges.admin.no_results": "No users found", + + "badges.rhs.create_badge": "+ Create achievement", + "badges.rhs.edit_badge": "Edit", + "badges.rhs.types": "Types", + "badges.rhs.create_type": "+ Create type", + + "badges.modal.create_badge_title": "Create Achievement", + "badges.modal.edit_badge_title": "Edit Achievement", + "badges.modal.field_name": "Name", + "badges.modal.field_name_placeholder": "Achievement name (max 20 chars)", + "badges.modal.field_description": "Description", + "badges.modal.field_description_placeholder": "Achievement description (max 120 chars)", + "badges.modal.field_image": "Emoji", + "badges.modal.field_image_placeholder": "Emoji name (e.g. star)", + "badges.modal.field_type": "Type", + "badges.modal.field_type_placeholder": "Select achievement type", + "badges.modal.field_multiple": "Can be granted multiple times", + "badges.modal.create_new_type": "+ Create new type", + "badges.modal.new_type_name": "Type name", + "badges.modal.new_type_name_placeholder": "Type name (max 20 chars)", + "badges.modal.new_type_everyone_create": "Everyone can create achievements", + "badges.modal.new_type_everyone_grant": "Everyone can grant achievements", + "badges.modal.btn_cancel": "Cancel", + "badges.modal.btn_create": "Create", + "badges.modal.btn_save": "Save", + "badges.modal.btn_creating": "Saving...", + "badges.modal.btn_delete": "Delete achievement", + "badges.modal.btn_confirm_delete": "Yes, delete", + "badges.modal.confirm_delete": "Are you sure?", + "badges.modal.confirm_delete_badge": "Delete achievement \"{name}\"?", + "badges.modal.error_generic": "An error occurred", + "badges.modal.error_type_name_required": "Enter type name", + "badges.modal.error_type_required": "Select achievement type", + "badges.modal.error_duplicate_name": "An achievement with this name already exists in this type", + "badges.modal.error_not_found_emoji": "This emoji was not found", + "badges.modal.create_type_title": "Create Type", + "badges.modal.edit_type_title": "Edit Type", + "badges.modal.btn_delete_type": "Delete type", + "badges.modal.delete_type": "Delete type", + "badges.modal.confirm_delete_type": "Delete type \"{name}\"?", + "badges.modal.btn_confirm_delete_type": "Yes, delete", + + "badges.types.badge_count": "{count, plural, one {# achievement} other {# achievements}}", + "badges.types.everyone_can_create": "Everyone creates", + "badges.types.everyone_can_grant": "Everyone grants", + "badges.types.is_default": "Default", + "badges.types.confirm_delete": "Delete type \"{name}\" and all its achievements?", + "badges.types.empty": "No types yet", + "badges.types.no_badges": "No achievements in this type", + "badges.rhs.back_to_types": "Back to types", + "badges.rhs.back_to_achievements": "Back to achievements", + + "badges.modal.allowlist_create": "Allowlist for creation", + "badges.modal.allowlist_create_help": "Users who can create achievements of this type.", + "badges.modal.allowlist_grant": "Allowlist for granting", + "badges.modal.allowlist_grant_help": "Users who can grant achievements of this type.", + "badges.modal.allowlist_placeholder": "user-1, user-2, user-3", + + "badges.grant.title": "Grant Achievement", + "badges.grant.intro": "Grant achievement to @{username}", + "badges.grant.field_badge": "Achievement", + "badges.grant.field_badge_placeholder": "Select an achievement", + "badges.grant.no_badges": "No achievements available", + "badges.grant.field_reason": "Reason", + "badges.grant.field_reason_placeholder": "Why is this achievement being granted? (optional)", + "badges.grant.notify_here": "Notify in channel", + "badges.grant.btn_grant": "Grant", + + "badges.revoke.btn": "Revoke", + "badges.revoke.confirm": "Revoke achievement?", + "badges.revoke.confirm_yes": "Yes", + + "badges.subscription.title_create": "Add Subscription", + "badges.subscription.title_delete": "Remove Subscription", + "badges.subscription.field_type": "Achievement Type", + "badges.subscription.field_type_placeholder": "Select achievement type", + "badges.subscription.no_types": "No types available", + "badges.subscription.btn_create": "Add", + "badges.subscription.btn_delete": "Remove", + + "badges.error.invalid_badge_id": "Achievement not specified", + "badges.error.invalid_user_id": "User not specified", + "badges.error.no_permission_grant": "Insufficient permissions to grant this achievement", + "badges.error.cannot_grant_badge": "Failed to grant achievement", + "badges.error.user_not_found": "User not found", + "badges.error.invalid_type_id": "Achievement type not specified", + "badges.error.no_permission_subscription": "Insufficient permissions to manage subscriptions", + "badges.error.cannot_create_subscription": "Failed to create subscription", + "badges.error.cannot_delete_subscription": "Failed to delete subscription", + "badges.error.ownership_not_found": "Ownership not found", + "badges.error.no_permission_revoke": "Insufficient permissions to revoke", + "badges.error.cannot_revoke": "Failed to revoke", + "badges.error.already_owned": "This achievement is already owned by this user", + + "badges.error.unknown": "An error occurred", + "badges.error.cannot_get_user": "Failed to get user data", + "badges.error.cannot_get_types": "Failed to load types", + "badges.error.cannot_get_badges": "Failed to load achievements", + "badges.error.invalid_request": "Invalid request format", + "badges.error.invalid_name": "Name is required", + "badges.error.invalid_image": "Emoji is required", + "badges.error.type_not_found": "Achievement type not found", + "badges.error.badge_not_found": "Achievement not found", + "badges.error.no_permission": "Insufficient permissions", + "badges.error.missing_badge_id": "Achievement ID is missing", + "badges.error.missing_type_id": "Type ID is missing", + "badges.error.cannot_create_badge": "Failed to create achievement", + "badges.error.cannot_create_type": "Failed to create type", + "badges.error.cannot_update_badge": "Failed to update achievement", + "badges.error.cannot_delete_badge": "Failed to delete achievement", + "badges.error.cannot_update_type": "Failed to update type", + "badges.error.cannot_delete_type": "Failed to delete type", + + "emoji_picker.activities": "Activities", + "emoji_picker.animals-nature": "Animals & Nature", + "emoji_picker.close": "Close", + "emoji_picker.custom": "Custom", + "emoji_picker.custom_emoji": "Custom Emoji", + "emoji_picker.emojiPicker.button.ariaLabel": "select an emoji", + "emoji_picker.emojiPicker.previewPlaceholder": "Select an Emoji", + "emoji_picker.flags": "Flags", + "emoji_picker.food-drink": "Food & Drink", + "emoji_picker.header": "Emoji Picker", + "emoji_picker.objects": "Objects", + "emoji_picker.people-body": "People & Body", + "emoji_picker.recent": "Recently Used", + "emoji_picker.search": "Search emojis", + "emoji_picker.searchResults": "Search Results", + "emoji_picker.search_emoji": "Search for an emoji", + "emoji_picker.skin_tone": "Skin tone", + "emoji_picker.smileys-emotion": "Smileys & Emotion", + "emoji_picker.symbols": "Symbols", + "emoji_picker.travel-places": "Travel Places", + "emoji_picker_item.emoji_aria_label": "{emojiName} emoji" } diff --git a/webapp/i18n/ru.json b/webapp/i18n/ru.json index a630386..64a9ff0 100644 --- a/webapp/i18n/ru.json +++ b/webapp/i18n/ru.json @@ -1,40 +1,181 @@ { "badges.loading": "Загрузка...", - "badges.no_badges_yet": "Значков пока нет.", - "badges.badge_not_found": "Значок не найден.", + "badges.no_badges_yet": "Достижений пока нет.", + "badges.empty.title": "Достижений пока нет", + "badges.empty.description": "Создайте первое достижение, чтобы отмечать заслуги участников команды.", + "badges.badge_not_found": "Достижение не найдено.", "badges.user_not_found": "Пользователь не найден.", "badges.unknown": "неизвестно", - "badges.rhs.all_badges": "Все значки", - "badges.rhs.my_badges": "Мои значки", - "badges.rhs.user_badges": "Значки @{username}", - "badges.rhs.badge_details": "Детали значка", + "badges.rhs.all_badges": "Все достижения", + "badges.rhs.my_badges": "Мои достижения", + "badges.rhs.user_badges": "Достижения @{username}", + "badges.rhs.badge_details": "Детали достижения", + "badges.label.name": "Название:", + "badges.label.description": "Описание:", "badges.label.type": "Тип: {typeName}", "badges.label.created_by": "Создал: {username}", "badges.label.granted_by": "Выдал: {username}", "badges.label.granted_at": "Выдан: {date}", "badges.label.reason": "Причина: {reason}", + "badges.label.count": "Количество: {count}", "badges.granted.not_yet": "Ещё не выдан.", "badges.granted.multiple": "Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.", "badges.granted.single": "Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.", "badges.granted_to": "Выдан:", + "badges.not_granted_yet": "Ещё никому не выдан", "badges.set_status": "Установить как статус", - "badges.grant_badge": "Выдать значок", + "badges.grant_badge": "Выдать достижение", "badges.and_more": "и ещё {count}. Нажмите, чтобы увидеть все.", - "badges.menu.open_list": "Открыть список всех значков.", - "badges.menu.create_badge": "Создать значок", - "badges.menu.create_type": "Создать тип значков", - "badges.menu.add_subscription": "Добавить подписку на значки", - "badges.menu.remove_subscription": "Удалить подписку на значки", + "badges.menu.open_list": "Достижения.", + "badges.menu.create_badge": "Создать достижение", + "badges.menu.create_type": "Создать тип достижений", + "badges.menu.add_subscription": "Добавить подписку на достижения", + "badges.menu.remove_subscription": "Удалить подписку на достижения", - "badges.sidebar.title": "Значки", - "badges.popover.title": "Значки", + "badges.sidebar.title": "Достижения", + "badges.popover.title": "Достижения", - "badges.admin.label": "Администратор достижений:", - "badges.admin.placeholder": "имя пользователя", - "badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки." + "badges.admin.label": "Администраторы достижений:", + "badges.admin.placeholder": "Начните вводить имя...", + "badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.", + "badges.admin.no_results": "Пользователь не найден", + + "badges.rhs.create_badge": "+ Создать достижение", + "badges.rhs.edit_badge": "Редактировать", + "badges.rhs.types": "Типы", + "badges.rhs.create_type": "+ Создать тип", + + "badges.modal.create_badge_title": "Создать достижение", + "badges.modal.edit_badge_title": "Редактировать достижение", + "badges.modal.field_name": "Название", + "badges.modal.field_name_placeholder": "Название достижения (макс. 20 символов)", + "badges.modal.field_description": "Описание", + "badges.modal.field_description_placeholder": "Описание достижения (макс. 120 символов)", + "badges.modal.field_image": "Эмодзи", + "badges.modal.field_image_placeholder": "Название эмодзи (напр. star)", + "badges.modal.field_type": "Тип", + "badges.modal.field_type_placeholder": "Выберите тип достижения", + "badges.modal.field_multiple": "Можно выдавать несколько раз", + "badges.modal.create_new_type": "+ Создать новый тип", + "badges.modal.new_type_name": "Название типа", + "badges.modal.new_type_name_placeholder": "Название типа (макс. 20 символов)", + "badges.modal.new_type_everyone_create": "Все могут создавать достижения", + "badges.modal.new_type_everyone_grant": "Все могут выдавать достижения", + "badges.modal.btn_cancel": "Отмена", + "badges.modal.btn_create": "Создать", + "badges.modal.btn_save": "Сохранить", + "badges.modal.btn_creating": "Сохранение...", + "badges.modal.btn_delete": "Удалить достижение", + "badges.modal.btn_confirm_delete": "Да, удалить", + "badges.modal.confirm_delete": "Вы уверены?", + "badges.modal.confirm_delete_badge": "Удалить достижение «{name}»?", + "badges.modal.error_generic": "Произошла ошибка", + "badges.modal.error_type_name_required": "Введите название типа", + "badges.modal.error_type_required": "Выберите тип достижения", + "badges.modal.error_duplicate_name": "Достижение в данном типе с таким названием уже существует", + "badges.modal.error_not_found_emoji": "Этот эмодзи не найден", + "badges.modal.create_type_title": "Создать тип", + "badges.modal.edit_type_title": "Редактировать тип", + "badges.modal.btn_delete_type": "Удалить тип", + "badges.modal.delete_type": "Удалить тип", + "badges.modal.confirm_delete_type": "Удалить тип «{name}»?", + "badges.modal.btn_confirm_delete_type": "Да, удалить", + + "badges.types.badge_count": "{count, plural, one {# достижение} few {# достижения} many {# достижений} other {# достижений}}", + "badges.types.everyone_can_create": "Все создают", + "badges.types.everyone_can_grant": "Все выдают", + "badges.types.is_default": "По умолчанию", + "badges.types.confirm_delete": "Удалить тип «{name}» и все его достижения?", + "badges.types.empty": "Типов пока нет", + "badges.types.no_badges": "В этом типе нет достижений", + "badges.rhs.back_to_types": "Назад к типам", + "badges.rhs.back_to_achievements": "Назад к достижениям", + + "badges.modal.allowlist_create": "Список допущенных к созданию", + "badges.modal.allowlist_create_help": "Пользователи, которые могут создавать достижения этого типа.", + "badges.modal.allowlist_grant": "Список допущенных к выдаче", + "badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать достижения этого типа.", + "badges.modal.allowlist_placeholder": "user-1, user-2, user-3", + + "badges.grant.title": "Выдать достижение", + "badges.grant.intro": "Выдать достижение пользователю @{username}", + "badges.grant.field_badge": "Достижение", + "badges.grant.field_badge_placeholder": "Выберите достижение", + "badges.grant.no_badges": "Нет доступных достижений", + "badges.grant.field_reason": "Причина", + "badges.grant.field_reason_placeholder": "За что выдаётся достижение? (необязательно)", + "badges.grant.notify_here": "Уведомить в канале", + "badges.grant.btn_grant": "Выдать", + + "badges.revoke.btn": "Снять достижение", + "badges.revoke.confirm": "Снять достижение?", + "badges.revoke.confirm_yes": "Да", + + "badges.subscription.title_create": "Добавить подписку", + "badges.subscription.title_delete": "Удалить подписку", + "badges.subscription.field_type": "Тип достижений", + "badges.subscription.field_type_placeholder": "Выберите тип достижений", + "badges.subscription.no_types": "Нет доступных типов", + "badges.subscription.btn_create": "Добавить", + "badges.subscription.btn_delete": "Удалить", + + "badges.error.invalid_badge_id": "Не указано достижение", + "badges.error.invalid_user_id": "Не указан пользователь", + "badges.error.no_permission_grant": "Недостаточно прав для выдачи этого достижения", + "badges.error.cannot_grant_badge": "Не удалось выдать достижение", + "badges.error.user_not_found": "Пользователь не найден", + "badges.error.invalid_type_id": "Не указан тип достижений", + "badges.error.no_permission_subscription": "Недостаточно прав для управления подписками", + "badges.error.cannot_create_subscription": "Не удалось создать подписку", + "badges.error.cannot_delete_subscription": "Не удалось удалить подписку", + "badges.error.ownership_not_found": "Выдача не найдена", + "badges.error.no_permission_revoke": "Недостаточно прав для снятия этого достижения", + "badges.error.cannot_revoke": "Не удалось снять достижение", + "badges.error.already_owned": "Это достижение уже выдано этому пользователю", + + "badges.error.unknown": "Произошла ошибка", + "badges.error.cannot_get_user": "Не удалось получить данные пользователя", + "badges.error.cannot_get_types": "Не удалось загрузить типы", + "badges.error.cannot_get_badges": "Не удалось загрузить достижения", + "badges.error.invalid_request": "Неверный формат запроса", + "badges.error.invalid_name": "Необходимо указать название", + "badges.error.invalid_image": "Необходимо указать эмодзи", + "badges.error.type_not_found": "Тип достижения не найден", + "badges.error.badge_not_found": "Достижение не найдено", + "badges.error.no_permission": "Недостаточно прав для выполнения действия", + "badges.error.missing_badge_id": "Не указан ID достижения", + "badges.error.missing_type_id": "Не указан ID типа", + "badges.error.cannot_create_badge": "Не удалось создать достижение", + "badges.error.cannot_create_type": "Не удалось создать тип", + "badges.error.cannot_update_badge": "Не удалось обновить достижение", + "badges.error.cannot_delete_badge": "Не удалось удалить достижение", + "badges.error.cannot_update_type": "Не удалось обновить тип", + "badges.error.cannot_delete_type": "Не удалось удалить тип", + + "emoji_picker.activities": "Мероприятия", + "emoji_picker.animals-nature": "Животные и природа", + "emoji_picker.close": "Закрыть", + "emoji_picker.custom": "Настраиваемое", + "emoji_picker.custom_emoji": "Пользовательские смайлики", + "emoji_picker.emojiPicker.button.ariaLabel": "выберите смайлик", + "emoji_picker.emojiPicker.previewPlaceholder": "Выберите смайлик", + "emoji_picker.flags": "Флаги", + "emoji_picker.food-drink": "Еда и напитки", + "emoji_picker.header": "Выбор смайликов", + "emoji_picker.objects": "Объекты", + "emoji_picker.people-body": "Люди и тело", + "emoji_picker.recent": "Недавно использованные", + "emoji_picker.search": "Поиск смайликов", + "emoji_picker.searchResults": "Результаты поиска", + "emoji_picker.search_emoji": "Поиск смайлика", + "emoji_picker.skin_tone": "Цвет кожи", + "emoji_picker.smileys-emotion": "Смайлы и эмоции", + "emoji_picker.symbols": "Символы", + "emoji_picker.travel-places": "Места путешествий", + "emoji_picker_item.emoji_aria_label": "смайлик {emojiName}" } diff --git a/webapp/package.json b/webapp/package.json index 621a88e..974771a 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -41,7 +41,7 @@ "@typescript-eslint/parser": "4.22.0", "babel-eslint": "10.1.0", "babel-jest": "26.6.3", - "babel-loader": "8.2.2", + "babel-loader": "^8.3.0", "babel-plugin-typescript-to-proptypes": "1.4.2", "css-loader": "5.2.4", "enzyme": "3.11.0", @@ -57,12 +57,11 @@ "jest": "26.6.3", "jest-canvas-mock": "2.3.1", "jest-junit": "12.0.0", - "loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz", "react-intl": "6.8.9", "sass": "1.86.0", "sass-loader": "11.0.1", "style-loader": "2.0.0", - "webpack": "5.34.0", + "webpack": "^5.54.0", "webpack-cli": "4.6.0" }, "dependencies": { @@ -73,9 +72,13 @@ "react": "17.0.2", "react-custom-scrollbars": "^4.2.1", "react-redux": "7.2.3", + "react-virtuoso": "^4.18.1", "redux": "4.0.5", "typescript": "4.2.4" }, + "resolutions": { + "@types/react": "17.0.3" + }, "jest": { "snapshotSerializers": [ "/node_modules/enzyme-to-json/serializer" diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 4d8660b..d12e25f 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -8,4 +8,17 @@ export default { RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view', RECEIVED_RHS_USER: pluginId + '_received_rhs_user', RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge', + RECEIVED_RHS_TYPE: pluginId + '_received_rhs_type', + OPEN_CREATE_BADGE_MODAL: pluginId + '_open_create_badge_modal', + CLOSE_CREATE_BADGE_MODAL: pluginId + '_close_create_badge_modal', + OPEN_EDIT_BADGE_MODAL: pluginId + '_open_edit_badge_modal', + CLOSE_EDIT_BADGE_MODAL: pluginId + '_close_edit_badge_modal', + OPEN_CREATE_TYPE_MODAL: pluginId + '_open_create_type_modal', + CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal', + OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal', + CLOSE_EDIT_TYPE_MODAL: pluginId + '_close_edit_type_modal', + OPEN_GRANT_MODAL: pluginId + '_open_grant_modal', + CLOSE_GRANT_MODAL: pluginId + '_close_grant_modal', + OPEN_SUBSCRIPTION_MODAL: pluginId + '_open_subscription_modal', + CLOSE_SUBSCRIPTION_MODAL: pluginId + '_close_subscription_modal', }; diff --git a/webapp/src/actions/actions.ts b/webapp/src/actions/actions.ts index 068cdf0..3815b54 100644 --- a/webapp/src/actions/actions.ts +++ b/webapp/src/actions/actions.ts @@ -1,14 +1,9 @@ import {AnyAction, Dispatch} from 'redux'; -import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {GetStateFunc} from 'mattermost-redux/types/actions'; -import {Client4} from 'mattermost-redux/client'; -import {IntegrationTypes} from 'mattermost-redux/action_types'; - import ActionTypes from 'action_types/'; -import {BadgeID} from 'types/badges'; -import {RHSState} from 'types/general'; +import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges'; +import {GrantModalData, RHSState, SubscriptionModalData} from 'types/general'; +import {id as pluginId} from '../manifest'; /** * Stores`showRHSPlugin` action returned by @@ -36,91 +31,106 @@ export function setRHSBadge(badgeID: BadgeID | null) { } export function setRHSView(view: RHSState) { - return { - type: ActionTypes.RECEIVED_RHS_VIEW, - data: view, + return (dispatch: Dispatch, getState: () => any) => { + const state = getState(); + const pluginState = state['plugins-' + pluginId]; + const currentView = pluginState?.rhsView; + dispatch({ + type: ActionTypes.RECEIVED_RHS_VIEW, + data: view, + prevView: currentView, + }); + return {data: true}; }; } -export function setTriggerId(triggerId: string) { +export function setRHSType(typeId: number | null, typeName: string | null) { return { - type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, - data: triggerId, + type: ActionTypes.RECEIVED_RHS_TYPE, + data: {typeId, typeName}, }; } export function openGrant(user?: string, badge?: string) { - return (dispatch: Dispatch, getState: GetStateFunc) => { - let command = '/badges grant'; - if (user) { - command += ` --user ${user}`; - } - - if (badge) { - command += ` --badge ${badge}`; - } - - clientExecuteCommand(dispatch, getState, command); - + return (dispatch: Dispatch) => { + dispatch(openGrantModal({prefillUser: user, prefillBadgeId: badge})); return {data: true}; }; } export function openCreateType() { - return (dispatch: Dispatch, getState: GetStateFunc) => { - const command = '/badges create type'; - clientExecuteCommand(dispatch, getState, command); - + return (dispatch: Dispatch) => { + dispatch(openCreateTypeModal()); return {data: true}; }; } export function openCreateBadge() { - return (dispatch: Dispatch, getState: GetStateFunc) => { - const command = '/badges create badge'; - clientExecuteCommand(dispatch, getState, command); - + return (dispatch: Dispatch) => { + dispatch(openCreateBadgeModal()); return {data: true}; }; } -export function openAddSubscription() { - return (dispatch: Dispatch, getState: GetStateFunc) => { - const command = '/badges subscription create'; - clientExecuteCommand(dispatch, getState, command); +export function openCreateBadgeModal() { + return {type: ActionTypes.OPEN_CREATE_BADGE_MODAL}; +} +export function closeCreateBadgeModal() { + return {type: ActionTypes.CLOSE_CREATE_BADGE_MODAL}; +} + +export function openEditBadgeModal(badge: BadgeDetails) { + return {type: ActionTypes.OPEN_EDIT_BADGE_MODAL, data: badge}; +} + +export function closeEditBadgeModal() { + return {type: ActionTypes.CLOSE_EDIT_BADGE_MODAL}; +} + +export function openCreateTypeModal() { + return {type: ActionTypes.OPEN_CREATE_TYPE_MODAL}; +} + +export function closeCreateTypeModal() { + return {type: ActionTypes.CLOSE_CREATE_TYPE_MODAL}; +} + +export function openEditTypeModal(badgeType: BadgeTypeDefinition) { + return {type: ActionTypes.OPEN_EDIT_TYPE_MODAL, data: badgeType}; +} + +export function closeEditTypeModal() { + return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL}; +} + +export function openGrantModal(data?: GrantModalData) { + return {type: ActionTypes.OPEN_GRANT_MODAL, data: data || {}}; +} + +export function closeGrantModal() { + return {type: ActionTypes.CLOSE_GRANT_MODAL}; +} + +export function openSubscriptionModal(data: SubscriptionModalData) { + return {type: ActionTypes.OPEN_SUBSCRIPTION_MODAL, data}; +} + +export function closeSubscriptionModal() { + return {type: ActionTypes.CLOSE_SUBSCRIPTION_MODAL}; +} + +export function openAddSubscription() { + return (dispatch: Dispatch) => { + dispatch(openSubscriptionModal({mode: 'create'})); return {data: true}; }; } export function openRemoveSubscription() { - return (dispatch: Dispatch, getState: GetStateFunc) => { - const command = '/badges subscription remove'; - clientExecuteCommand(dispatch, getState, command); - + return (dispatch: Dispatch) => { + dispatch(openSubscriptionModal({mode: 'delete'})); return {data: true}; }; } -export async function clientExecuteCommand(dispatch: Dispatch, getState: GetStateFunc, command: string) { - let currentChannel = getCurrentChannel(getState()); - const currentTeamId = getCurrentTeamId(getState()); - - // Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded) - if (!currentChannel) { - currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square'); - } - - const args = { - channel_id: currentChannel?.id, - team_id: currentTeamId, - }; - - try { - //@ts-ignore Typing in mattermost-redux is wrong - const data = await Client4.executeCommand(command, args); - dispatch(setTriggerId(data?.trigger_id)); - } catch (error) { - console.error(error); //eslint-disable-line no-console - } -} diff --git a/webapp/src/client/api.ts b/webapp/src/client/api.ts index a5e275e..d5513d9 100644 --- a/webapp/src/client/api.ts +++ b/webapp/src/client/api.ts @@ -5,7 +5,7 @@ import {Client4} from 'mattermost-redux/client'; import {ClientError} from 'mattermost-redux/client/client4'; import manifest from 'manifest'; -import {AllBadgesBadge, BadgeDetails, BadgeID, UserBadge} from 'types/badges'; +import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, GrantBadgeRequest, RevokeOwnershipRequest, SubscriptionRequest, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges'; export default class Client { private url: string; @@ -41,6 +41,64 @@ export default class Client { } } + async getTypes(): Promise { + try { + const res = await this.doGet(`${this.url}/getTypes`); + return res as GetTypesResponse; + } catch { + return {types: [], can_create_type: false, can_edit_type: false}; + } + } + + async createBadge(req: CreateBadgeRequest): Promise { + return await this.doPost(`${this.url}/createBadge`, req) as Badge; + } + + async createType(req: CreateTypeRequest): Promise { + return await this.doPost(`${this.url}/createType`, req) as BadgeTypeDefinition; + } + + async updateBadge(req: UpdateBadgeRequest): Promise { + return await this.doPut(`${this.url}/updateBadge`, req) as Badge; + } + + async deleteBadge(badgeID: BadgeID): Promise { + await this.doDelete(`${this.url}/deleteBadge/${badgeID}`); + } + + async updateType(req: UpdateTypeRequest): Promise { + return await this.doPut(`${this.url}/updateType`, req) as BadgeTypeDefinition; + } + + async deleteType(typeID: string): Promise { + await this.doDelete(`${this.url}/deleteType/${typeID}`); + } + + async grantBadge(req: GrantBadgeRequest): Promise { + await this.doPost(`${this.url}/grantBadge`, req); + } + + async createSubscription(req: SubscriptionRequest): Promise { + await this.doPost(`${this.url}/createSubscription`, req); + } + + async deleteSubscription(req: SubscriptionRequest): Promise { + await this.doPost(`${this.url}/deleteSubscription`, req); + } + + async revokeOwnership(req: RevokeOwnershipRequest): Promise { + await this.doPost(`${this.url}/revokeOwnership`, req); + } + + async getChannelSubscriptions(channelID: string): Promise { + try { + const res = await this.doGet(`${this.url}/getChannelSubscriptions/${channelID}`); + return res as BadgeTypeDefinition[]; + } catch { + return []; + } + } + private doGet = async (url: string, headers: {[x:string]: string} = {}) => { headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); @@ -63,4 +121,75 @@ export default class Client { url, }); } + + private doPost = async (url: string, body: any, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'post', + body: JSON.stringify(body), + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } + + private doPut = async (url: string, body: any, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'put', + body: JSON.stringify(body), + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } + + private doDelete = async (url: string, headers: {[x:string]: string} = {}) => { + headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset()); + + const options = { + method: 'delete', + headers, + }; + + const response = await fetch(url, Client4.getOptions(options)); + + if (response.ok) { + return response.json(); + } + + const text = await response.text(); + + throw new ClientError(Client4.url, { + message: text || '', + status_code: response.status, + url, + }); + } } diff --git a/webapp/src/components/admin/badges_admin_setting.tsx b/webapp/src/components/admin/badges_admin_setting.tsx index ba3f4f4..fc28d5d 100644 --- a/webapp/src/components/admin/badges_admin_setting.tsx +++ b/webapp/src/components/admin/badges_admin_setting.tsx @@ -1,6 +1,7 @@ -/* eslint-disable react/prop-types */ import React, {useCallback} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; + +import UserMultiSelect from 'components/user_multi_select'; type Props = { id: string; @@ -16,10 +17,8 @@ type Props = { } const BadgesAdminSetting: React.FC = ({id, value, disabled, onChange, setSaveNeeded}) => { - const intl = useIntl(); - - const handleChange = useCallback((e: React.ChangeEvent) => { - onChange(id, e.target.value); + const handleChange = useCallback((newValue: string) => { + onChange(id, newValue); setSaveNeeded(); }, [id, onChange, setSaveNeeded]); @@ -28,25 +27,19 @@ const BadgesAdminSetting: React.FC = ({id, value, disabled, onChange, set
-
diff --git a/webapp/src/components/back_button/back_button.scss b/webapp/src/components/back_button/back_button.scss new file mode 100644 index 0000000..a81143c --- /dev/null +++ b/webapp/src/components/back_button/back_button.scss @@ -0,0 +1,13 @@ +.BackButton { + background: none; + border: none; + padding: 0; + font-size: 12px; + color: var(--button-bg, #166de0); + cursor: pointer; + text-align: left; + + &:hover { + text-decoration: underline; + } +} diff --git a/webapp/src/components/back_button/back_button.tsx b/webapp/src/components/back_button/back_button.tsx new file mode 100644 index 0000000..02997ee --- /dev/null +++ b/webapp/src/components/back_button/back_button.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import {RHSState} from '../../types/general'; + +import './back_button.scss'; + +type Props = { + targetView: RHSState; + onNavigate: (view: RHSState) => void; + children: React.ReactNode; +} + +const BackButton: React.FC = ({ + targetView, + onNavigate, + children, +}) => { + return ( + + ); +}; + +export default BackButton; diff --git a/webapp/src/components/utils/badge_image.tsx b/webapp/src/components/badge_image/badge_image.tsx similarity index 94% rename from webapp/src/components/utils/badge_image.tsx rename to webapp/src/components/badge_image/badge_image.tsx index ff4773f..4818e54 100644 --- a/webapp/src/components/utils/badge_image.tsx +++ b/webapp/src/components/badge_image/badge_image.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Badge} from '../../types/badges'; -import RenderEmoji from '../utils/emoji'; +import RenderEmoji from '../emoji/emoji'; import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants'; type Props = { diff --git a/webapp/src/components/badge_modal/badge_modal.scss b/webapp/src/components/badge_modal/badge_modal.scss new file mode 100644 index 0000000..f4411fe --- /dev/null +++ b/webapp/src/components/badge_modal/badge_modal.scss @@ -0,0 +1,422 @@ +@keyframes badgeModalBackdropIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes badgeModalBackdropOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes badgeModalDialogIn { + from { + opacity: 0; + transform: translateY(-40px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes badgeModalDialogOut { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(-40px); + } +} + +.BadgeModal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + + &__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + animation: badgeModalBackdropIn 0.2s ease-out; + } + + &__dialog { + position: relative; + z-index: 1; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + border-radius: 8px; + box-shadow: 0 20px 32px rgba(0, 0, 0, 0.12); + width: 480px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: badgeModalDialogIn 0.2s ease-out; + } + + &--closing { + .BadgeModal__backdrop { + animation: badgeModalBackdropOut 0.15s ease-in forwards; + } + + .BadgeModal__dialog { + animation: badgeModalDialogOut 0.15s ease-in forwards; + } + } + + &--compact { + .BadgeModal__body { + overflow: visible; + } + + .BadgeModal__dialog { + overflow: visible; + } + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 0; + + h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + .close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--center-channel-color, #3d3c40); + opacity: 0.56; + padding: 0; + line-height: 1; + + &:hover { + opacity: 1; + } + } + } + + &__body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; + } + + .grant-intro { + font-size: 14px; + margin: 0 0 16px; + opacity: 0.72; + } + + &__footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + + .form-group { + margin-bottom: 16px; + + label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + opacity: 0.64; + + .required { + color: var(--error-text, #d24b4e); + margin-left: 2px; + } + } + + > input[type='text'], + > select, + > textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + font-size: 14px; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + + &:focus { + border-color: var(--button-bg, #166de0); + outline: none; + } + } + + > textarea { + resize: vertical; + min-height: 60px; + } + + .emoji-input { + display: flex; + align-items: center; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + background: var(--center-channel-bg, #fff); + + &:focus-within { + border-color: var(--button-bg, #166de0); + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 0; + border: none; + background: none; + color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56); + cursor: pointer; + + &:hover { + color: var(--center-channel-color, #3d3c40); + } + + .emoticon { + display: block; + } + } + + .emojisprite, + .emoticon { + margin-right: 4px; + } + + input[type='text'] { + flex: 1; + border: none; + background: transparent; + padding: 8px 12px 8px 0; + + &:focus { + outline: none; + border-color: transparent; + box-shadow: none; + } + } + } + } + + .checkbox-group { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + + label { + font-size: 14px; + font-weight: normal; + margin: 0; + text-transform: none; + opacity: 1; + cursor: pointer; + } + } + + .inline-type-section { + padding: 12px; + margin-top: 8px; + border: 1px dashed rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.24); + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04); + } + + .btn { + padding: 8px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: none; + + &--primary { + background: var(--button-bg, #166de0); + color: var(--button-color, #fff); + + &:hover { + opacity: 0.88; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &--cancel { + background: transparent; + color: var(--center-channel-color, #3d3c40); + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + } + + &--danger { + background: var(--error-text, #d24b4e); + color: #fff; + + &:hover { + opacity: 0.88; + } + } + } + + .error-message { + color: var(--error-text, #d24b4e); + font-size: 13px; + margin-top: 8px; + } + + .delete-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + display: flex; + justify-content: space-between; + align-items: center; + } + + .confirm-delete { + display: flex; + align-items: center; + gap: 8px; + + span { + font-size: 13px; + color: var(--error-text, #d24b4e); + } + } + + .type-select { + position: relative; + + &__trigger { + width: 100%; + padding: 8px 12px; + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + font-size: 14px; + background: var(--center-channel-bg, #fff); + color: var(--center-channel-color, #3d3c40); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + text-align: left; + + &:hover { + border-color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32); + } + } + + &__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__arrow { + font-size: 12px; + opacity: 0.56; + margin-left: 8px; + flex-shrink: 0; + } + + &__dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--center-channel-bg, #fff); + border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 10; + max-height: 160px; + overflow-y: auto; + } + + &__option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + font-size: 14px; + + &:hover { + background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + } + + &--selected { + background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08); + font-weight: 600; + } + + &--create { + border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08); + color: var(--button-bg, #166de0); + font-weight: 600; + } + } + + &__option-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__delete-btn { + background: none; + border: none; + color: var(--center-channel-color, #3d3c40); + opacity: 0.4; + cursor: pointer; + padding: 0 4px; + font-size: 12px; + line-height: 1; + flex-shrink: 0; + + &:hover { + opacity: 1; + color: var(--error-text, #d24b4e); + } + } + + } +} diff --git a/webapp/src/components/badge_modal/emoji_picker.tsx b/webapp/src/components/badge_modal/emoji_picker.tsx new file mode 100644 index 0000000..96e301b --- /dev/null +++ b/webapp/src/components/badge_modal/emoji_picker.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface EmojiPickerOverlayProps { + target: () => HTMLElement | null; + container?: () => HTMLElement | null; + show: boolean; + onHide: () => void; + onEmojiClick: (emoji: any) => void; + rightOffset?: number; + defaultHorizontalPosition?: 'left' | 'right'; + onExited?: () => void; + hideCustomEmojiButton?: boolean; +} + +const EmojiPickerOverlay: React.FC = (props) => { + const Overlay = (window as any).Components?.EmojiPickerOverlay; + + if (!Overlay) { + return null; + } + + return ; +}; + +export default EmojiPickerOverlay; diff --git a/webapp/src/components/badge_modal/index.tsx b/webapp/src/components/badge_modal/index.tsx new file mode 100644 index 0000000..d0abea0 --- /dev/null +++ b/webapp/src/components/badge_modal/index.tsx @@ -0,0 +1,470 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; + +import {useDispatch, useSelector} from 'react-redux'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import {GlobalState} from 'mattermost-redux/types/store'; + +import RenderEmoji from 'components/emoji/emoji'; + +import {isCreateBadgeModalVisible, getEditBadgeModalData, getEmojiMap} from 'selectors'; +import {closeCreateBadgeModal, closeEditBadgeModal, setRHSView} from 'actions/actions'; +import {RHS_STATE_ALL} from '../../constants'; +import {BadgeFormData, BadgeTypeDefinition, TypeFormData} from 'types/badges'; +import Client from 'client/api'; +import {getServerErrorId} from 'utils/helpers'; +import CloseIcon from 'components/icons/close_icon'; +import EmojiIcon from 'components/icons/emoji_icon'; + +import ConfirmDialog from 'components/confirm_dialog/confirm_dialog'; + +import EmojiPickerOverlay from './emoji_picker'; +import InlineTypeForm from './inline_type_form'; +import TypeSelect from './type_select'; + +import './badge_modal.scss'; + +const NEW_TYPE_VALUE = '__new__'; + +const emptyBadgeForm: BadgeFormData = { + name: '', + description: '', + image: '', + badgeType: '', + multiple: false, +}; + +const emptyTypeForm: TypeFormData = { + name: '', + everyoneCanCreate: false, + everyoneCanGrant: false, + allowlistCanCreate: '', + allowlistCanGrant: '', +}; + +const BadgeModal: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const createVisible = useSelector(isCreateBadgeModalVisible); + const editData = useSelector(getEditBadgeModalData); + const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state)); + const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state)); + const isOpen = createVisible || editData !== null; + const isEditMode = editData !== null; + + const [form, setForm] = useState(emptyBadgeForm); + const [newTypeForm, setNewTypeForm] = useState(emptyTypeForm); + const [types, setTypes] = useState([]); + const [showCreateType, setShowCreateType] = useState(false); + const [canCreateType, setCanCreateType] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState(null); + const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [closing, setClosing] = useState(false); + const modalRef = useRef(null); + const dialogRef = useRef(null); + + const updateForm = useCallback((updates: Partial) => { + setForm((prev) => ({...prev, ...updates})); + }, []); + + const updateTypeForm = useCallback((updates: Partial) => { + setNewTypeForm((prev) => ({...prev, ...updates})); + }, []); + + const emojiData = (window as any)?.useGetEmojiSelectorData?.(); + const { + emojiButtonRef, + calculateRightOffSet, + } = emojiData || {}; + + useEffect(() => { + if (!isOpen) { + return; + } + const fetchTypes = async () => { + const client = new Client(); + const resp = await client.getTypes(); + setTypes(resp.types); + setCanCreateType(resp.can_create_type); + if (!isEditMode && resp.types.length > 0) { + const defaultType = resp.types.find((t) => t.is_default); + setForm((prev) => ({...prev, badgeType: String(defaultType ? defaultType.id : resp.types[0].id)})); + } + }; + fetchTypes(); + + if (isEditMode && editData) { + setForm({ + name: editData.name, + description: editData.description, + image: editData.image, + badgeType: String(editData.type), + multiple: editData.multiple, + }); + } else { + setForm(emptyBadgeForm); + } + setShowCreateType(false); + setNewTypeForm(emptyTypeForm); + setError(null); + setConfirmDelete(false); + setConfirmDeleteTypeId(null); + setTypeDropdownOpen(false); + setShowEmojiPicker(false); + setLoading(false); + }, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps + + const doClose = useCallback(() => { + if (createVisible) { + dispatch(closeCreateBadgeModal()); + } + if (editData) { + dispatch(closeEditBadgeModal()); + } + setClosing(false); + }, [dispatch, createVisible, editData]); + + const handleClose = useCallback(() => { + setClosing(true); + setTimeout(doClose, 150); + }, [doClose]); + + const handleTypeSelect = useCallback((val: string) => { + if (val === NEW_TYPE_VALUE) { + setShowCreateType(true); + updateForm({badgeType: ''}); + } else { + setShowCreateType(false); + updateForm({badgeType: val}); + } + setTypeDropdownOpen(false); + setConfirmDeleteTypeId(null); + }, [updateForm]); + + const handleEmojiSelect = (emoji: any) => { + if (emoji.short_name) { + updateForm({image: emoji.short_name}); + } else if (emoji.name) { + updateForm({image: emoji.name}); + } + setShowEmojiPicker(false); + }; + + const handleDeleteType = useCallback(async (typeId: string) => { + if (confirmDeleteTypeId !== typeId) { + setConfirmDeleteTypeId(typeId); + return; + } + try { + const client = new Client(); + await client.deleteType(typeId); + const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId; + setTypes((prev) => prev.filter(removeById)); + if (form.badgeType === typeId) { + updateForm({badgeType: ''}); + } + } catch (err) { + setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'})); + } + setConfirmDeleteTypeId(null); + }, [confirmDeleteTypeId, form.badgeType, updateForm, intl]); + + const handleSubmit = useCallback(async () => { + setLoading(true); + setError(null); + try { + const client = new Client(); + let typeID = form.badgeType; + if (showCreateType) { + if (!newTypeForm.name.trim()) { + setError(intl.formatMessage({id: 'badges.modal.error_type_name_required', defaultMessage: 'Введите название типа'})); + setLoading(false); + return; + } + const createdType = await client.createType({ + name: newTypeForm.name.trim(), + everyone_can_create: newTypeForm.everyoneCanCreate, + everyone_can_grant: newTypeForm.everyoneCanGrant, + allowlist_can_create: newTypeForm.allowlistCanCreate.trim(), + allowlist_can_grant: newTypeForm.allowlistCanGrant.trim(), + channel_id: channelId, + }); + typeID = String(createdType.id); + } + if (!typeID) { + setError(intl.formatMessage({id: 'badges.modal.error_type_required', defaultMessage: 'Выберите тип достижения'})); + setLoading(false); + return; + } + const allBadges = await client.getAllBadges(); + const trimmedName = form.name.trim().toLowerCase(); + const duplicate = allBadges.find( + (b) => b.name.toLowerCase() === trimmedName && + String(b.type) === typeID && + (!isEditMode || !editData || b.id !== editData.id), + ); + if (!emojiMap.has(form.image)) { + setError(intl.formatMessage({id: 'badges.modal.error_not_found_emoji', defaultMessage: 'Этот эмодзи не найден'})); + setLoading(false); + return; + } + if (duplicate) { + setError(intl.formatMessage({id: 'badges.modal.error_duplicate_name', defaultMessage: 'Достижение в данном типе с таким названием уже существует'})); + setLoading(false); + return; + } + if (isEditMode && editData) { + await client.updateBadge({ + id: String(editData.id), + name: form.name.trim(), + description: form.description.trim(), + image: form.image.trim(), + type: typeID, + multiple: form.multiple, + }); + } else { + await client.createBadge({ + name: form.name.trim(), + description: form.description.trim(), + image: form.image.trim(), + type: typeID, + multiple: form.multiple, + channel_id: channelId, + }); + } + handleClose(); + dispatch(setRHSView(RHS_STATE_ALL)); + } catch (err) { + setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'})); + } finally { + setLoading(false); + } + }, [form, showCreateType, newTypeForm, isEditMode, editData, handleClose, intl, channelId, dispatch, emojiMap]); + + const handleDelete = useCallback(async () => { + if (!editData) { + return; + } + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setLoading(true); + setError(null); + try { + const client = new Client(); + await client.deleteBadge(editData.id); + handleClose(); + dispatch(setRHSView(RHS_STATE_ALL)); + } catch (err) { + setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'})); + } finally { + setLoading(false); + } + }, [editData, confirmDelete, handleClose, intl, dispatch]); + + if (!isOpen && !closing) { + return null; + } + + const title = isEditMode + ? intl.formatMessage({id: 'badges.modal.edit_badge_title', defaultMessage: 'Редактировать достижение'}) + : intl.formatMessage({id: 'badges.modal.create_badge_title', defaultMessage: 'Создать достижение'}); + const submitLabel = isEditMode + ? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'}) + : intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'}); + + return ( +
+
+
+
+

{title}

+ +
+
+
+ + updateForm({name: e.target.value})} + maxLength={20} + placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название достижения (макс. 20 символов)'})} + /> +
+
+ +