From 4fcabad652e144c63cf47c0281497ef85cc81c5c Mon Sep 17 00:00:00 2001 From: "tamas.albert" Date: Wed, 18 Mar 2026 11:43:39 +0200 Subject: [PATCH] feature: Create UI for user and team management (cherry picked from commit 20c33c10528dcff3eda272659ec5c1ea393818d6) chore (upstream): Fix build error in org plugin (team filter) (cherry picked from commit 0746adc456ce8de59392a50b9dfb32d1719f936a) --- backend/plugins/org/api/store.go | 619 ++++++++++++++++++++- backend/plugins/org/api/team.go | 3 +- backend/plugins/org/api/team_crud.go | 228 ++++++++ backend/plugins/org/api/team_users_crud.go | 384 +++++++++++++ backend/plugins/org/api/types.go | 18 +- backend/plugins/org/api/user_crud.go | 230 ++++++++ backend/plugins/org/impl/impl.go | 26 + backend/plugins/org/tasks/user_account.go | 3 +- config-ui/src/api/index.ts | 4 + config-ui/src/api/pipeline/index.ts | 19 + config-ui/src/api/team/index.ts | 50 ++ config-ui/src/api/user/index.ts | 50 ++ config-ui/src/app/routrer.tsx | 10 + config-ui/src/config/paths.ts | 2 + config-ui/src/routes/index.ts | 2 + config-ui/src/routes/layout/config.tsx | 16 + config-ui/src/routes/layout/layout.tsx | 42 +- config-ui/src/routes/layout/loader.ts | 53 +- config-ui/src/routes/teams/home/index.tsx | 381 +++++++++++++ config-ui/src/routes/teams/index.ts | 1 + config-ui/src/routes/users/home/index.tsx | 524 +++++++++++++++++ config-ui/src/routes/users/index.ts | 1 + config-ui/src/types/index.ts | 2 + config-ui/src/types/team.ts | 33 ++ config-ui/src/types/user.ts | 35 ++ 25 files changed, 2713 insertions(+), 23 deletions(-) create mode 100644 backend/plugins/org/api/team_crud.go create mode 100644 backend/plugins/org/api/team_users_crud.go create mode 100644 backend/plugins/org/api/user_crud.go create mode 100644 config-ui/src/api/team/index.ts create mode 100644 config-ui/src/api/user/index.ts create mode 100644 config-ui/src/routes/teams/home/index.tsx create mode 100644 config-ui/src/routes/teams/index.ts create mode 100644 config-ui/src/routes/users/home/index.tsx create mode 100644 config-ui/src/routes/users/index.ts create mode 100644 config-ui/src/types/team.ts create mode 100644 config-ui/src/types/user.ts diff --git a/backend/plugins/org/api/store.go b/backend/plugins/org/api/store.go index 8c50c10c6da..d90f59759da 100644 --- a/backend/plugins/org/api/store.go +++ b/backend/plugins/org/api/store.go @@ -18,22 +18,41 @@ limitations under the License. package api import ( + "reflect" + "sort" + "strings" + "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain" helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" - "reflect" ) type store interface { findAllUsers() ([]user, errors.Error) findAllTeams() ([]team, errors.Error) + findTeamsPaginated(page, pageSize int, nameFilter string, grouped bool) ([]teamTree, int64, errors.Error) findAllAccounts() ([]account, errors.Error) findAllUserAccounts() ([]userAccount, errors.Error) findAllProjectMapping() ([]projectMapping, errors.Error) deleteAll(i interface{}) errors.Error save(items []interface{}) errors.Error + findTeamById(id string) (*crossdomain.Team, errors.Error) + createTeam(t *crossdomain.Team) errors.Error + updateTeam(t *crossdomain.Team) errors.Error + deleteTeam(id string) errors.Error + findUsersPaginated(page, pageSize int, emailFilter string) ([]userWithTeams, int64, errors.Error) + findUsersByIds(userIds []string) ([]crossdomain.User, errors.Error) + findTeamsByIds(teamIds []string) ([]crossdomain.Team, errors.Error) + findUserById(id string) (*crossdomain.User, errors.Error) + createUser(u *crossdomain.User) errors.Error + updateUser(u *crossdomain.User) errors.Error + deleteUser(id string) errors.Error + findTeamUsersByTeamId(teamId string) ([]crossdomain.TeamUser, errors.Error) + findTeamUsersByUserId(userId string) ([]crossdomain.TeamUser, errors.Error) + replaceTeamUsersForTeam(teamId string, userIds []string) errors.Error + replaceTeamUsersForUser(userId string, teamIds []string) errors.Error } type dbStore struct { @@ -60,6 +79,7 @@ func (d *dbStore) findAllUsers() ([]user, errors.Error) { } return u.fromDomainLayer(uu, tus), nil } + func (d *dbStore) findAllTeams() ([]team, errors.Error) { var tt []crossdomain.Team err := d.db.All(&tt) @@ -69,6 +89,7 @@ func (d *dbStore) findAllTeams() ([]team, errors.Error) { var t *team return t.fromDomainLayer(tt), nil } + func (d *dbStore) findAllAccounts() ([]account, errors.Error) { var aa []crossdomain.Account err := d.db.All(&aa) @@ -122,3 +143,599 @@ func (d *dbStore) save(items []interface{}) errors.Error { d.driver.Close() return nil } + +func (d *dbStore) findTeamsPaginated(page, pageSize int, nameFilter string, grouped bool) ([]teamTree, int64, errors.Error) { + if !grouped { + clauses := []dal.Clause{dal.From(&crossdomain.Team{})} + if nameFilter != "" { + clauses = append(clauses, dal.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(nameFilter)+"%")) + } + + count, err := d.db.Count(clauses...) + if err != nil { + return nil, 0, err + } + + var domainTeams []crossdomain.Team + err = d.db.All(&domainTeams, + append(clauses, + dal.Orderby("sorting_index ASC, created_at DESC, name ASC"), + dal.Offset((page-1)*pageSize), + dal.Limit(pageSize), + )..., + ) + if err != nil { + return nil, 0, err + } + + var t *team + flatTeams := t.fromDomainLayer(domainTeams) + teamIds := make([]string, 0, len(flatTeams)) + for _, item := range flatTeams { + teamIds = append(teamIds, item.Id) + } + teamUserCounts, err := d.findTeamUserCounts(teamIds) + if err != nil { + return nil, 0, err + } + + result := make([]teamTree, 0, len(flatTeams)) + for _, item := range flatTeams { + result = append(result, teamTree{ + Id: item.Id, + Name: item.Name, + Alias: item.Alias, + ParentId: item.ParentId, + SortingIndex: item.SortingIndex, + UserCount: teamUserCounts[item.Id], + }) + } + return result, count, nil + } + + parentClauses := []dal.Clause{ + dal.From(&crossdomain.Team{}), + dal.Where("(parent_id IS NULL OR parent_id = '')"), + } + if nameFilter != "" { + parentClauses = append(parentClauses, dal.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(nameFilter)+"%")) + } + + count, err := d.db.Count(parentClauses...) + if err != nil { + return nil, 0, err + } + + var parentDomainTeams []crossdomain.Team + var count int64 + + if nameFilter == "" { + // Original behavior: in grouped mode without a name filter, only parents are + // considered, and pagination is applied directly on them. + parentClauses := []dal.Clause{ + dal.From(&crossdomain.Team{}), + dal.Where("(parent_id IS NULL OR parent_id = '')"), + } + + _, err := d.db.Count(parentClauses...) + if err != nil { + return nil, 0, err + } + err = d.db.All(&parentDomainTeams, + append(parentClauses, + dal.Orderby("sorting_index ASC, name ASC"), + dal.Offset((page-1)*pageSize), + dal.Limit(pageSize), + )..., + ) + if err != nil { + return nil, 0, err + } + } else { + // When a name filter is provided in grouped mode, we want to return parents + // whose own names match, as well as parents of children whose names match. + lowerFilter := strings.ToLower(nameFilter) + like := "%" + lowerFilter + "%" + // 1. Parents whose own names match the filter. + var filteredParents []crossdomain.Team + err := d.db.All(&filteredParents, + dal.From(&crossdomain.Team{}), + dal.Where("(parent_id IS NULL OR parent_id = '')"), + dal.Where("LOWER(name) LIKE ?", like), + ) + if err != nil { + return nil, 0, err + } + // 2. Children whose names match the filter. + var filteredChildren []crossdomain.Team + err = d.db.All(&filteredChildren, + dal.From(&crossdomain.Team{}), + dal.Where("parent_id IS NOT NULL AND parent_id <> ''"), + dal.Where("LOWER(name) LIKE ?", like), + ) + if err != nil { + return nil, 0, err + } + // 3. Collect distinct parent IDs from both matching parents and parents of + // matching children. + parentIdSet := make(map[string]struct{}) + for _, p := range filteredParents { + if p.Id != "" { + parentIdSet[p.Id] = struct{}{} + } + } + for _, c := range filteredChildren { + if c.ParentId != "" { + parentIdSet[c.ParentId] = struct{}{} + } + } + if len(parentIdSet) == 0 { + // No parents (or children) matched the filter. + return []teamTree{}, 0, nil + } + parentIds := make([]string, 0, len(parentIdSet)) + for id := range parentIdSet { + parentIds = append(parentIds, id) + } + // The total count is the number of distinct parent IDs. + count = int64(len(parentIds)) + // 4. Load the paginated parents based on the collected IDs, preserving the + // original ordering. + err = d.db.All(&parentDomainTeams, + dal.From(&crossdomain.Team{}), + dal.Where("(parent_id IS NULL OR parent_id = '')"), + dal.Where("id IN (?)", parentIds), + dal.Orderby("sorting_index ASC, name ASC"), + dal.Offset((page-1)*pageSize), + dal.Limit(pageSize), + )..., + ) + if err != nil { + return nil, 0, err + } + + if len(parentDomainTeams) == 0 { + return []teamTree{}, count, nil + } + + parentIds := make([]string, 0, len(parentDomainTeams)) + for _, parent := range parentDomainTeams { + parentIds = append(parentIds, parent.Id) + } + + var childDomainTeams []crossdomain.Team + err := d.db.All( + &childDomainTeams, + dal.From(&crossdomain.Team{}), + dal.Where("parent_id IN ?", parentIds), + dal.Orderby("sorting_index ASC, name ASC"), + ) + if err != nil { + return nil, 0, err + } + + var t *team + parentTeams := t.fromDomainLayer(parentDomainTeams) + childTeams := t.fromDomainLayer(childDomainTeams) + teamIds := make([]string, 0, len(parentTeams)+len(childTeams)) + for _, parent := range parentTeams { + teamIds = append(teamIds, parent.Id) + } + for _, child := range childTeams { + teamIds = append(teamIds, child.Id) + } + teamUserCounts, err := d.findTeamUserCounts(teamIds) + if err != nil { + return nil, 0, err + } + + childrenByParent := make(map[string][]teamTree, len(parentTeams)) + for _, child := range childTeams { + childrenByParent[child.ParentId] = append(childrenByParent[child.ParentId], teamTree{ + Id: child.Id, + Name: child.Name, + Alias: child.Alias, + ParentId: child.ParentId, + SortingIndex: child.SortingIndex, + UserCount: teamUserCounts[child.Id], + }) + } + + result := make([]teamTree, 0, len(parentTeams)) + for _, parent := range parentTeams { + result = append(result, teamTree{ + Id: parent.Id, + Name: parent.Name, + Alias: parent.Alias, + ParentId: parent.ParentId, + SortingIndex: parent.SortingIndex, + UserCount: teamUserCounts[parent.Id], + Children: childrenByParent[parent.Id], + }) + } + + return result, count, nil +} + +func (d *dbStore) findTeamUserCounts(teamIds []string) (map[string]int, errors.Error) { + counts := make(map[string]int, len(teamIds)) + if len(teamIds) == 0 { + return counts, nil + } + + var teamUsers []crossdomain.TeamUser + err := d.db.All(&teamUsers, dal.Where("team_id IN ?", teamIds)) + if err != nil { + return nil, err + } + + usersByTeam := make(map[string]map[string]struct{}, len(teamIds)) + for _, teamUser := range teamUsers { + if usersByTeam[teamUser.TeamId] == nil { + usersByTeam[teamUser.TeamId] = make(map[string]struct{}) + } + usersByTeam[teamUser.TeamId][teamUser.UserId] = struct{}{} + } + + for teamId, users := range usersByTeam { + counts[teamId] = len(users) + } + + return counts, nil +} + +func (d *dbStore) findTeamById(id string) (*crossdomain.Team, errors.Error) { + var t crossdomain.Team + err := d.db.First(&t, dal.Where("id = ?", id)) + if err != nil { + return nil, err + } + return &t, nil +} + +func (d *dbStore) createTeam(t *crossdomain.Team) errors.Error { + return d.db.Create(t) +} + +func (d *dbStore) updateTeam(t *crossdomain.Team) errors.Error { + return d.db.Update(t) +} + +func (d *dbStore) deleteTeam(id string) errors.Error { + err := d.db.Delete(&crossdomain.TeamUser{}, dal.Where("team_id = ?", id)) + if err != nil { + return err + } + return d.db.Delete(&crossdomain.Team{}, dal.Where("id = ?", id)) +} + +func (d *dbStore) findUsersPaginated(page, pageSize int, emailFilter string) ([]userWithTeams, int64, errors.Error) { + clauses := []dal.Clause{dal.From(&crossdomain.User{})} + if emailFilter != "" { + clauses = append(clauses, dal.Where("LOWER(email) LIKE ?", "%"+strings.ToLower(emailFilter)+"%")) + } + count, err := d.db.Count(clauses...) + if err != nil { + return nil, 0, err + } + var uu []crossdomain.User + err = d.db.All(&uu, + append(clauses, + dal.Orderby("name ASC"), + dal.Offset((page-1)*pageSize), + dal.Limit(pageSize), + )..., + ) + if err != nil { + return nil, 0, err + } + // fetch team associations for the returned users + userIds := make([]string, 0, len(uu)) + var tus []crossdomain.TeamUser + if len(uu) > 0 { + for _, u := range uu { + userIds = append(userIds, u.Id) + } + err = d.db.All(&tus, dal.Where("user_id IN ?", userIds)) + if err != nil { + return nil, 0, err + } + } + + // fetch account associations for the returned users + var uas []crossdomain.UserAccount + if len(userIds) > 0 { + err = d.db.All(&uas, dal.Where("user_id IN ?", userIds)) + if err != nil { + return nil, 0, err + } + } + + teamIdsSet := make(map[string]struct{}, len(tus)) + teamsByUser := make(map[string]map[string]struct{}, len(uu)) + for _, teamUser := range tus { + if teamUser.UserId == "" || teamUser.TeamId == "" { + continue + } + teamIdsSet[teamUser.TeamId] = struct{}{} + if teamsByUser[teamUser.UserId] == nil { + teamsByUser[teamUser.UserId] = make(map[string]struct{}) + } + teamsByUser[teamUser.UserId][teamUser.TeamId] = struct{}{} + } + + teamNameById := make(map[string]string, len(teamIdsSet)) + if len(teamIdsSet) > 0 { + teamIds := make([]string, 0, len(teamIdsSet)) + for teamId := range teamIdsSet { + teamIds = append(teamIds, teamId) + } + + var teams []crossdomain.Team + err = d.db.All(&teams, dal.Where("id IN ?", teamIds)) + if err != nil { + return nil, 0, err + } + for _, team := range teams { + teamNameById[team.Id] = team.Name + } + } + + accountsByUser := make(map[string]map[string]struct{}, len(uu)) + for _, userAccount := range uas { + if userAccount.UserId == "" || userAccount.AccountId == "" { + continue + } + if accountsByUser[userAccount.UserId] == nil { + accountsByUser[userAccount.UserId] = make(map[string]struct{}) + } + accountsByUser[userAccount.UserId][userAccount.AccountId] = struct{}{} + } + + result := make([]userWithTeams, 0, len(uu)) + for _, domainUser := range uu { + userTeamIdsSet := teamsByUser[domainUser.Id] + userTeamIds := make([]string, 0, len(userTeamIdsSet)) + for teamId := range userTeamIdsSet { + userTeamIds = append(userTeamIds, teamId) + } + sort.Strings(userTeamIds) + + userTeamNames := make([]string, 0, len(userTeamIds)) + for _, teamId := range userTeamIds { + if teamName, exists := teamNameById[teamId]; exists && teamName != "" { + userTeamNames = append(userTeamNames, teamName) + } + } + sort.Strings(userTeamNames) + + userAccountIdsSet := accountsByUser[domainUser.Id] + userAccountIds := make([]string, 0, len(userAccountIdsSet)) + for accountId := range userAccountIdsSet { + userAccountIds = append(userAccountIds, accountId) + } + sort.Strings(userAccountIds) + accountSources := extractAccountSources(userAccountIds) + + result = append(result, userWithTeams{ + Id: domainUser.Id, + Name: domainUser.Name, + Email: domainUser.Email, + TeamIds: strings.Join(userTeamIds, ";"), + TeamCount: len(userTeamIds), + TeamNames: userTeamNames, + AccountCount: len(userAccountIds), + AccountSources: accountSources, + }) + } + + return result, count, nil +} + +func extractAccountSources(accountIds []string) []string { + if len(accountIds) == 0 { + return []string{} + } + + sourceSet := make(map[string]struct{}, len(accountIds)) + for _, accountId := range accountIds { + source := accountSourceFromAccountId(accountId) + if source == "" { + continue + } + sourceSet[source] = struct{}{} + } + + sources := make([]string, 0, len(sourceSet)) + for source := range sourceSet { + sources = append(sources, source) + } + sort.Strings(sources) + return sources +} + +func accountSourceFromAccountId(accountId string) string { + if accountId == "" { + return "" + } + + separatorIndex := strings.Index(accountId, ":") + if separatorIndex <= 0 { + return "Unknown" + } + + pluginName := strings.ToLower(strings.TrimSpace(accountId[:separatorIndex])) + switch pluginName { + case "github": + return "GitHub" + case "gitlab": + return "GitLab" + case "jira": + return "Jira" + case "azuredevops", "azuredevops_go": + return "Azure DevOps" + case "bitbucket": + return "Bitbucket" + case "bitbucket_server": + return "Bitbucket Server" + case "gh-copilot": + return "GitHub Copilot" + case "sonarqube": + return "SonarQube" + case "tapd": + return "TAPD" + } + + parts := strings.FieldsFunc(pluginName, func(r rune) bool { + return r == '_' || r == '-' + }) + if len(parts) == 0 { + return "Unknown" + } + + titled := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + titled = append(titled, titleCaseWord(part)) + } + if len(titled) == 0 { + return "Unknown" + } + + return strings.Join(titled, " ") +} + +func titleCaseWord(word string) string { + if word == "" { + return "" + } + if len(word) == 1 { + return strings.ToUpper(word) + } + return strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) +} + +func (d *dbStore) findUsersByIds(userIds []string) ([]crossdomain.User, errors.Error) { + if len(userIds) == 0 { + return []crossdomain.User{}, nil + } + + var users []crossdomain.User + err := d.db.All(&users, dal.Where("id IN ?", userIds)) + if err != nil { + return nil, err + } + + return users, nil +} + +func (d *dbStore) findTeamsByIds(teamIds []string) ([]crossdomain.Team, errors.Error) { + if len(teamIds) == 0 { + return []crossdomain.Team{}, nil + } + + var teams []crossdomain.Team + err := d.db.All(&teams, dal.Where("id IN ?", teamIds)) + if err != nil { + return nil, err + } + + return teams, nil +} + +func (d *dbStore) findUserById(id string) (*crossdomain.User, errors.Error) { + var u crossdomain.User + err := d.db.First(&u, dal.Where("id = ?", id)) + if err != nil { + return nil, err + } + return &u, nil +} + +func (d *dbStore) createUser(u *crossdomain.User) errors.Error { + return d.db.Create(u) +} + +func (d *dbStore) updateUser(u *crossdomain.User) errors.Error { + return d.db.Update(u) +} + +func (d *dbStore) deleteUser(id string) errors.Error { + err := d.db.Delete(&crossdomain.TeamUser{}, dal.Where("user_id = ?", id)) + if err != nil { + return err + } + err = d.db.Delete(&crossdomain.UserAccount{}, dal.Where("user_id = ?", id)) + if err != nil { + return err + } + return d.db.Delete(&crossdomain.User{}, dal.Where("id = ?", id)) +} + +func (d *dbStore) findTeamUsersByUserId(userId string) ([]crossdomain.TeamUser, errors.Error) { + var tus []crossdomain.TeamUser + err := d.db.All(&tus, dal.Where("user_id = ?", userId)) + if err != nil { + return nil, err + } + return tus, nil +} + +func (d *dbStore) findTeamUsersByTeamId(teamId string) ([]crossdomain.TeamUser, errors.Error) { + var tus []crossdomain.TeamUser + err := d.db.All(&tus, dal.Where("team_id = ?", teamId)) + if err != nil { + return nil, err + } + return tus, nil +} + +func (d *dbStore) replaceTeamUsersForTeam(teamId string, userIds []string) errors.Error { + err := d.db.Delete(&crossdomain.TeamUser{}, dal.Where("team_id = ?", teamId)) + if err != nil { + return err + } + + seen := make(map[string]struct{}, len(userIds)) + for _, userId := range userIds { + if userId == "" { + continue + } + if _, exists := seen[userId]; exists { + continue + } + seen[userId] = struct{}{} + + err = d.db.Create(&crossdomain.TeamUser{ + TeamId: teamId, + UserId: userId, + }) + if err != nil { + return err + } + } + + return nil +} + +func (d *dbStore) replaceTeamUsersForUser(userId string, teamIds []string) errors.Error { + err := d.db.Delete(&crossdomain.TeamUser{}, dal.Where("user_id = ?", userId)) + if err != nil { + return err + } + for _, teamId := range teamIds { + if teamId == "" { + continue + } + err = d.db.Create(&crossdomain.TeamUser{ + TeamId: teamId, + UserId: userId, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/backend/plugins/org/api/team.go b/backend/plugins/org/api/team.go index 133a97e8e81..69df2af1aba 100644 --- a/backend/plugins/org/api/team.go +++ b/backend/plugins/org/api/team.go @@ -18,10 +18,11 @@ limitations under the License. package api import ( + "net/http" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain" "github.com/apache/incubator-devlake/core/plugin" - "net/http" "github.com/gocarina/gocsv" ) diff --git a/backend/plugins/org/api/team_crud.go b/backend/plugins/org/api/team_crud.go new file mode 100644 index 00000000000..0196791bed2 --- /dev/null +++ b/backend/plugins/org/api/team_crud.go @@ -0,0 +1,228 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "strconv" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/google/uuid" +) + +type paginatedTeams struct { + Count int64 `json:"count"` + Teams []teamTree `json:"teams"` +} + +type teamTree struct { + Id string `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + ParentId string `json:"parentId"` + SortingIndex int `json:"sortingIndex"` + UserCount int `json:"userCount"` + Children []teamTree `json:"children,omitempty"` +} + +// ListTeams returns teams with pagination support +// @Summary List teams +// @Description GET /plugins/org/teams?page=1&pageSize=50 +// @Tags plugins/org +// @Produce json +// @Param page query int false "page number (default 1)" +// @Param pageSize query int false "page size (default 50)" +// @Param name query string false "filter by name (case-insensitive, partial match)" +// @Param grouped query bool false "when true, returns parent teams with nested children" +// @Success 200 {object} paginatedTeams +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams [get] +func (h *Handlers) ListTeams(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + page, pageSize := 1, 50 + if p := input.Query.Get("page"); p != "" { + if v, e := strconv.Atoi(p); e == nil && v > 0 { + page = v + } + } + if ps := input.Query.Get("pageSize"); ps != "" { + if v, e := strconv.Atoi(ps); e == nil && v > 0 { + pageSize = v + } + } + grouped := false + if groupedParam := input.Query.Get("grouped"); groupedParam != "" { + groupedValue, parseErr := strconv.ParseBool(groupedParam) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "grouped must be a boolean value") + } + grouped = groupedValue + } + nameFilter := input.Query.Get("name") + teams, count, err := h.store.findTeamsPaginated(page, pageSize, nameFilter, grouped) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: paginatedTeams{Count: count, Teams: teams}, + Status: http.StatusOK, + }, nil +} + +// GetTeamById returns a single team by ID +// @Summary Get a team +// @Description get a team by ID +// @Tags plugins/org +// @Produce json +// @Param teamId path string true "team ID" +// @Success 200 {object} team +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams/{teamId} [get] +func (h *Handlers) GetTeamById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + teamId := input.Params["teamId"] + if teamId == "" { + return nil, errors.BadInput.New("teamId is required") + } + t, err := h.store.findTeamById(teamId) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: team{ + Id: t.Id, + Name: t.Name, + Alias: t.Alias, + ParentId: t.ParentId, + SortingIndex: t.SortingIndex, + }, + Status: http.StatusOK, + }, nil +} + +type createTeamsRequest struct { + Teams []team `json:"teams"` +} + +// CreateTeams creates one or more teams +// @Summary Create teams +// @Description create one or more teams +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param body body createTeamsRequest true "teams to create" +// @Success 201 {array} team +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams [post] +func (h *Handlers) CreateTeams(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var req createTeamsRequest + err := helper.Decode(input.Body, &req, nil) + if err != nil { + return nil, errors.BadInput.Wrap(err, "invalid request body") + } + if len(req.Teams) == 0 { + return nil, errors.BadInput.New("at least one team is required") + } + var created []team + for _, t := range req.Teams { + id := uuid.New().String() + domainTeam := &crossdomain.Team{ + DomainEntity: domainlayer.DomainEntity{Id: id}, + Name: t.Name, + Alias: t.Alias, + ParentId: t.ParentId, + SortingIndex: t.SortingIndex, + } + if err := h.store.createTeam(domainTeam); err != nil { + return nil, err + } + t.Id = id + created = append(created, t) + } + return &plugin.ApiResourceOutput{Body: created, Status: http.StatusCreated}, nil +} + +// UpdateTeamById updates a team by ID +// @Summary Update a team +// @Description update a team by ID +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param teamId path string true "team ID" +// @Param body body team true "team fields to update" +// @Success 200 {object} team +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams/{teamId} [put] +func (h *Handlers) UpdateTeamById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + teamId := input.Params["teamId"] + if teamId == "" { + return nil, errors.BadInput.New("teamId is required") + } + existing, err := h.store.findTeamById(teamId) + if err != nil { + return nil, err + } + var t team + if e := helper.Decode(input.Body, &t, nil); e != nil { + return nil, errors.BadInput.Wrap(e, "invalid request body") + } + existing.Name = t.Name + existing.Alias = t.Alias + existing.ParentId = t.ParentId + existing.SortingIndex = t.SortingIndex + if err := h.store.updateTeam(existing); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: team{ + Id: existing.Id, + Name: existing.Name, + Alias: existing.Alias, + ParentId: existing.ParentId, + SortingIndex: existing.SortingIndex, + }, + Status: http.StatusOK, + }, nil +} + +// DeleteTeamById deletes a team by ID and its associated team_users +// @Summary Delete a team +// @Description delete a team by ID (cascades to team_users) +// @Tags plugins/org +// @Param teamId path string true "team ID" +// @Success 200 +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams/{teamId} [delete] +func (h *Handlers) DeleteTeamById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + teamId := input.Params["teamId"] + if teamId == "" { + return nil, errors.BadInput.New("teamId is required") + } + if err := h.store.deleteTeam(teamId); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Status: http.StatusOK}, nil +} diff --git a/backend/plugins/org/api/team_users_crud.go b/backend/plugins/org/api/team_users_crud.go new file mode 100644 index 00000000000..33c7eb18521 --- /dev/null +++ b/backend/plugins/org/api/team_users_crud.go @@ -0,0 +1,384 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "sort" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +type teamUsersResponse struct { + TeamId string `json:"teamId"` + UserIds []string `json:"userIds"` + Count int `json:"count"` +} + +type updateTeamUsersRequest struct { + UserIds []string `json:"userIds"` +} + +type userTeamsResponse struct { + UserId string `json:"userId"` + TeamIds []string `json:"teamIds"` + TeamNames []string `json:"teamNames"` + Count int `json:"count"` +} + +type updateUserTeamsRequest struct { + TeamIds []string `json:"teamIds"` +} + +func (h *Handlers) sanitizeTeamUserIds(userIds []string) ([]string, errors.Error) { + if len(userIds) == 0 { + return []string{}, nil + } + + uniqueUserIds := make([]string, 0, len(userIds)) + seen := make(map[string]struct{}, len(userIds)) + for _, userId := range userIds { + if userId == "" { + continue + } + if _, exists := seen[userId]; exists { + continue + } + seen[userId] = struct{}{} + uniqueUserIds = append(uniqueUserIds, userId) + } + + if len(uniqueUserIds) == 0 { + return []string{}, nil + } + + users, err := h.store.findUsersByIds(uniqueUserIds) + if err != nil { + return nil, err + } + + existingUserIds := make(map[string]struct{}, len(users)) + for _, u := range users { + existingUserIds[u.Id] = struct{}{} + } + + filteredUserIds := make([]string, 0, len(uniqueUserIds)) + for _, userId := range uniqueUserIds { + if _, exists := existingUserIds[userId]; exists { + filteredUserIds = append(filteredUserIds, userId) + } + } + + return filteredUserIds, nil +} + +func (h *Handlers) listTeamUserIds(teamId string) ([]string, errors.Error) { + teamUsers, err := h.store.findTeamUsersByTeamId(teamId) + if err != nil { + return nil, err + } + + userIds := make([]string, 0, len(teamUsers)) + seen := make(map[string]struct{}, len(teamUsers)) + for _, teamUser := range teamUsers { + if teamUser.UserId == "" { + continue + } + if _, exists := seen[teamUser.UserId]; exists { + continue + } + seen[teamUser.UserId] = struct{}{} + userIds = append(userIds, teamUser.UserId) + } + sort.Strings(userIds) + return userIds, nil +} + +func (h *Handlers) sanitizeUserTeamIds(teamIds []string) ([]string, errors.Error) { + if len(teamIds) == 0 { + return []string{}, nil + } + + uniqueTeamIds := make([]string, 0, len(teamIds)) + seen := make(map[string]struct{}, len(teamIds)) + for _, teamId := range teamIds { + if teamId == "" { + continue + } + if _, exists := seen[teamId]; exists { + continue + } + seen[teamId] = struct{}{} + uniqueTeamIds = append(uniqueTeamIds, teamId) + } + + if len(uniqueTeamIds) == 0 { + return []string{}, nil + } + + teams, err := h.store.findTeamsByIds(uniqueTeamIds) + if err != nil { + return nil, err + } + + existingTeamIds := make(map[string]struct{}, len(teams)) + for _, t := range teams { + existingTeamIds[t.Id] = struct{}{} + } + + filteredTeamIds := make([]string, 0, len(uniqueTeamIds)) + for _, teamId := range uniqueTeamIds { + if _, exists := existingTeamIds[teamId]; exists { + filteredTeamIds = append(filteredTeamIds, teamId) + } + } + + sort.Strings(filteredTeamIds) + return filteredTeamIds, nil +} + +func (h *Handlers) listUserTeamIds(userId string) ([]string, errors.Error) { + teamUsers, err := h.store.findTeamUsersByUserId(userId) + if err != nil { + return nil, err + } + + teamIds := make([]string, 0, len(teamUsers)) + seen := make(map[string]struct{}, len(teamUsers)) + for _, teamUser := range teamUsers { + if teamUser.TeamId == "" { + continue + } + if _, exists := seen[teamUser.TeamId]; exists { + continue + } + seen[teamUser.TeamId] = struct{}{} + teamIds = append(teamIds, teamUser.TeamId) + } + sort.Strings(teamIds) + return teamIds, nil +} + +func (h *Handlers) listUserTeamData(userId string) ([]string, []string, errors.Error) { + teamIds, err := h.listUserTeamIds(userId) + if err != nil { + return nil, nil, err + } + if len(teamIds) == 0 { + return []string{}, []string{}, nil + } + + teams, err := h.store.findTeamsByIds(teamIds) + if err != nil { + return nil, nil, err + } + + teamNameById := make(map[string]string, len(teams)) + for _, t := range teams { + teamNameById[t.Id] = t.Name + } + + teamNames := make([]string, 0, len(teamIds)) + for _, teamId := range teamIds { + if teamName, exists := teamNameById[teamId]; exists && teamName != "" { + teamNames = append(teamNames, teamName) + } + } + sort.Strings(teamNames) + + return teamIds, teamNames, nil +} + +// GetTeamUsersByTeamId returns all user IDs assigned to a team +// @Summary List team users +// @Description get all user IDs assigned to a team +// @Tags plugins/org +// @Produce json +// @Param teamId path string true "team ID" +// @Success 200 {object} teamUsersResponse +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams/{teamId}/users [get] +func (h *Handlers) GetTeamUsersByTeamId(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + teamId := input.Params["teamId"] + if teamId == "" { + return nil, errors.BadInput.New("teamId is required") + } + + if _, err := h.store.findTeamById(teamId); err != nil { + return nil, err + } + + userIds, err := h.listTeamUserIds(teamId) + if err != nil { + return nil, err + } + + return &plugin.ApiResourceOutput{ + Body: teamUsersResponse{ + TeamId: teamId, + UserIds: userIds, + Count: len(userIds), + }, + Status: http.StatusOK, + }, nil +} + +// UpdateTeamUsersByTeamId replaces user assignments for a team +// @Summary Replace team users +// @Description replace user assignments for a team by team ID +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param teamId path string true "team ID" +// @Param body body updateTeamUsersRequest true "list of user IDs to assign to the team" +// @Success 200 {object} teamUsersResponse +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/teams/{teamId}/users [put] +func (h *Handlers) UpdateTeamUsersByTeamId(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + teamId := input.Params["teamId"] + if teamId == "" { + return nil, errors.BadInput.New("teamId is required") + } + + if _, err := h.store.findTeamById(teamId); err != nil { + return nil, err + } + + var req updateTeamUsersRequest + if err := helper.Decode(input.Body, &req, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid request body") + } + validUserIds, err := h.sanitizeTeamUserIds(req.UserIds) + if err != nil { + return nil, err + } + + if err := h.store.replaceTeamUsersForTeam(teamId, validUserIds); err != nil { + return nil, err + } + + userIds, err := h.listTeamUserIds(teamId) + if err != nil { + return nil, err + } + + return &plugin.ApiResourceOutput{ + Body: teamUsersResponse{ + TeamId: teamId, + UserIds: userIds, + Count: len(userIds), + }, + Status: http.StatusOK, + }, nil +} + +// GetUserTeamsByUserId returns all team IDs and names assigned to a user +// @Summary List user teams +// @Description get all team IDs and names assigned to a user +// @Tags plugins/org +// @Produce json +// @Param userId path string true "user ID" +// @Success 200 {object} userTeamsResponse +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users/{userId}/teams [get] +func (h *Handlers) GetUserTeamsByUserId(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + userId := input.Params["userId"] + if userId == "" { + return nil, errors.BadInput.New("userId is required") + } + + if _, err := h.store.findUserById(userId); err != nil { + return nil, err + } + + teamIds, teamNames, err := h.listUserTeamData(userId) + if err != nil { + return nil, err + } + + return &plugin.ApiResourceOutput{ + Body: userTeamsResponse{ + UserId: userId, + TeamIds: teamIds, + TeamNames: teamNames, + Count: len(teamIds), + }, + Status: http.StatusOK, + }, nil +} + +// UpdateUserTeamsByUserId replaces team assignments for a user +// @Summary Replace user teams +// @Description replace team assignments for a user by user ID +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param userId path string true "user ID" +// @Param body body updateUserTeamsRequest true "list of team IDs to assign to the user" +// @Success 200 {object} userTeamsResponse +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users/{userId}/teams [put] +func (h *Handlers) UpdateUserTeamsByUserId(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + userId := input.Params["userId"] + if userId == "" { + return nil, errors.BadInput.New("userId is required") + } + + if _, err := h.store.findUserById(userId); err != nil { + return nil, err + } + + var req updateUserTeamsRequest + if err := helper.Decode(input.Body, &req, nil); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid request body") + } + + validTeamIds, err := h.sanitizeUserTeamIds(req.TeamIds) + if err != nil { + return nil, err + } + + if err := h.store.replaceTeamUsersForUser(userId, validTeamIds); err != nil { + return nil, err + } + + teamIds, teamNames, err := h.listUserTeamData(userId) + if err != nil { + return nil, err + } + + return &plugin.ApiResourceOutput{ + Body: userTeamsResponse{ + UserId: userId, + TeamIds: teamIds, + TeamNames: teamNames, + Count: len(teamIds), + }, + Status: http.StatusOK, + }, nil +} diff --git a/backend/plugins/org/api/types.go b/backend/plugins/org/api/types.go index 6dc296011b0..c067883e759 100644 --- a/backend/plugins/org/api/types.go +++ b/backend/plugins/org/api/types.go @@ -78,10 +78,10 @@ var fakeProjectMapping = []projectMapping{ } type user struct { - Id string - Name string - Email string - TeamIds string + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + TeamIds string `json:"teamIds"` } func (*user) fromDomainLayer(users []crossdomain.User, teamUsers []crossdomain.TeamUser) []user { @@ -205,11 +205,11 @@ func (au *userAccount) fromDomainLayer(accountUsers []crossdomain.UserAccount) [ } type team struct { - Id string - Name string - Alias string - ParentId string - SortingIndex int + Id string `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + ParentId string `json:"parentId"` + SortingIndex int `json:"sortingIndex"` } func (*team) fromDomainLayer(tt []crossdomain.Team) []team { diff --git a/backend/plugins/org/api/user_crud.go b/backend/plugins/org/api/user_crud.go new file mode 100644 index 00000000000..d5704ab083c --- /dev/null +++ b/backend/plugins/org/api/user_crud.go @@ -0,0 +1,230 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/http" + "strconv" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/google/uuid" +) + +type paginatedUsers struct { + Count int64 `json:"count"` + Users []userWithTeams `json:"users"` +} + +type userWithTeams struct { + Id string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + TeamIds string `json:"teamIds"` + TeamCount int `json:"teamCount"` + TeamNames []string `json:"teamNames"` + AccountCount int `json:"accountCount"` + AccountSources []string `json:"accountSources"` +} + +type createUsersRequest struct { + Users []user `json:"users"` +} + +// ListUsers returns users with pagination support +// @Summary List users +// @Description GET /plugins/org/users?page=1&pageSize=50&email=example +// @Tags plugins/org +// @Produce json +// @Param page query int false "page number (default 1)" +// @Param pageSize query int false "page size (default 50)" +// @Param email query string false "filter by email (case-insensitive, partial match)" +// @Success 200 {object} paginatedUsers +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users [get] +func (h *Handlers) ListUsers(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + page, pageSize := 1, 50 + if p := input.Query.Get("page"); p != "" { + if v, e := strconv.Atoi(p); e == nil && v > 0 { + page = v + } + } + if ps := input.Query.Get("pageSize"); ps != "" { + if v, e := strconv.Atoi(ps); e == nil && v > 0 { + pageSize = v + } + } + emailFilter := input.Query.Get("email") + users, count, err := h.store.findUsersPaginated(page, pageSize, emailFilter) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: paginatedUsers{Count: count, Users: users}, + Status: http.StatusOK, + }, nil +} + +// GetUserById returns a single user by ID +// @Summary Get a user +// @Description get a user by ID +// @Tags plugins/org +// @Produce json +// @Param userId path string true "user ID" +// @Success 200 {object} user +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users/{userId} [get] +func (h *Handlers) GetUserById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + userId := input.Params["userId"] + if userId == "" { + return nil, errors.BadInput.New("userId is required") + } + u, err := h.store.findUserById(userId) + if err != nil { + return nil, err + } + // fetch team associations + var tus []crossdomain.TeamUser + teamIds := "" + tus, err = h.store.findTeamUsersByUserId(userId) + if err == nil && len(tus) > 0 { + var ids []string + for _, tu := range tus { + ids = append(ids, tu.TeamId) + } + teamIds = strings.Join(ids, ";") + } + return &plugin.ApiResourceOutput{ + Body: user{ + Id: u.Id, + Name: u.Name, + Email: u.Email, + TeamIds: teamIds, + }, + Status: http.StatusOK, + }, nil +} + +// CreateUsers creates one or more users +// @Summary Create users +// @Description create one or more users +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param body body createUsersRequest true "users to create" +// @Success 201 {array} user +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users [post] +func (h *Handlers) CreateUsers(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var req createUsersRequest + err := helper.Decode(input.Body, &req, nil) + if err != nil { + return nil, errors.BadInput.Wrap(err, "invalid request body") + } + if len(req.Users) == 0 { + return nil, errors.BadInput.New("at least one user is required") + } + var created []user + for _, u := range req.Users { + id := uuid.New().String() + domainUser := &crossdomain.User{ + DomainEntity: domainlayer.DomainEntity{Id: id}, + Name: u.Name, + Email: u.Email, + } + if err := h.store.createUser(domainUser); err != nil { + return nil, err + } + u.Id = id + created = append(created, u) + } + return &plugin.ApiResourceOutput{Body: created, Status: http.StatusCreated}, nil +} + +// UpdateUserById updates a user by ID +// @Summary Update a user +// @Description update a user by ID +// @Tags plugins/org +// @Accept json +// @Produce json +// @Param userId path string true "user ID" +// @Param body body user true "user fields to update" +// @Success 200 {object} user +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 404 {object} shared.ApiBody "Not Found" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users/{userId} [put] +func (h *Handlers) UpdateUserById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + userId := input.Params["userId"] + if userId == "" { + return nil, errors.BadInput.New("userId is required") + } + existing, err := h.store.findUserById(userId) + if err != nil { + return nil, err + } + var u user + if e := helper.Decode(input.Body, &u, nil); e != nil { + return nil, errors.BadInput.Wrap(e, "invalid request body") + } + existing.Name = u.Name + existing.Email = u.Email + if err := h.store.updateUser(existing); err != nil { + return nil, err + } + // replace team associations + if err := h.store.replaceTeamUsersForUser(userId, strings.Split(u.TeamIds, ";")); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: user{ + Id: existing.Id, + Name: existing.Name, + Email: existing.Email, + TeamIds: u.TeamIds, + }, + Status: http.StatusOK, + }, nil +} + +// DeleteUserById deletes a user by ID and its associated team_users and user_accounts +// @Summary Delete a user +// @Description delete a user by ID (cascades to team_users and user_accounts) +// @Tags plugins/org +// @Param userId path string true "user ID" +// @Success 200 +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/org/users/{userId} [delete] +func (h *Handlers) DeleteUserById(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + userId := input.Params["userId"] + if userId == "" { + return nil, errors.BadInput.New("userId is required") + } + if err := h.store.deleteUser(userId); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Status: http.StatusOK}, nil +} diff --git a/backend/plugins/org/impl/impl.go b/backend/plugins/org/impl/impl.go index e68257eec31..5133dde0a1c 100644 --- a/backend/plugins/org/impl/impl.go +++ b/backend/plugins/org/impl/impl.go @@ -121,5 +121,31 @@ func (p Org) ApiResources() map[string]map[string]plugin.ApiResourceHandler { "GET": p.handlers.GetProjectMapping, "PUT": p.handlers.CreateProjectMapping, }, + "teams": { + "GET": p.handlers.ListTeams, + "POST": p.handlers.CreateTeams, + }, + "teams/:teamId": { + "GET": p.handlers.GetTeamById, + "PUT": p.handlers.UpdateTeamById, + "DELETE": p.handlers.DeleteTeamById, + }, + "teams/:teamId/users": { + "GET": p.handlers.GetTeamUsersByTeamId, + "PUT": p.handlers.UpdateTeamUsersByTeamId, + }, + "users": { + "GET": p.handlers.ListUsers, + "POST": p.handlers.CreateUsers, + }, + "users/:userId": { + "GET": p.handlers.GetUserById, + "PUT": p.handlers.UpdateUserById, + "DELETE": p.handlers.DeleteUserById, + }, + "users/:userId/teams": { + "GET": p.handlers.GetUserTeamsByUserId, + "PUT": p.handlers.UpdateUserTeamsByUserId, + }, } } diff --git a/backend/plugins/org/tasks/user_account.go b/backend/plugins/org/tasks/user_account.go index 45f187e3b8c..0d5e99fc877 100644 --- a/backend/plugins/org/tasks/user_account.go +++ b/backend/plugins/org/tasks/user_account.go @@ -18,12 +18,13 @@ limitations under the License. package tasks import ( + "reflect" + "github.com/apache/incubator-devlake/core/dal" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/models/domainlayer/crossdomain" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" - "reflect" ) var ConnectUserAccountsExactMeta = plugin.SubTaskMeta{ diff --git a/config-ui/src/api/index.ts b/config-ui/src/api/index.ts index f211e564038..2ca80e17f79 100644 --- a/config-ui/src/api/index.ts +++ b/config-ui/src/api/index.ts @@ -28,6 +28,8 @@ import * as scope from './scope'; import * as scopeConfig from './scope-config'; import * as store from './store'; import * as task from './task'; +import * as team from './team'; +import * as user from './user'; const migrate = () => request('/proceed-db-migration'); const ping = () => request('/ping'); @@ -44,6 +46,8 @@ export const API = { scopeConfig, store, task, + team, + user, migrate, ping, version, diff --git a/config-ui/src/api/pipeline/index.ts b/config-ui/src/api/pipeline/index.ts index ad06d28b399..ba86d223614 100644 --- a/config-ui/src/api/pipeline/index.ts +++ b/config-ui/src/api/pipeline/index.ts @@ -21,9 +21,28 @@ import { request } from '@/utils'; import { SubTasksRes } from './types'; +type CreatePipelineTask = { + plugin: string; + subtasks?: string[]; + options?: Record; +}; + +type CreatePipelineRequest = { + name: string; + plan: CreatePipelineTask[][]; + labels?: string[]; + priority?: number; +}; + export const list = (params: Pagination): Promise<{ count: number; pipelines: IPipeline[] }> => request('/pipelines', { data: params }); +export const create = (data: CreatePipelineRequest): Promise => + request('/pipelines', { + method: 'post', + data, + }); + export const get = (id: ID) => request(`/pipelines/${id}`); export const remove = (id: ID) => diff --git a/config-ui/src/api/team/index.ts b/config-ui/src/api/team/index.ts new file mode 100644 index 00000000000..604d530d82a --- /dev/null +++ b/config-ui/src/api/team/index.ts @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import type { ITeam, ITeamUsers } from '@/types'; +import { request } from '@/utils'; + +export const list = (data: Pagination & { name?: string; grouped?: boolean }): Promise<{ count: number; teams: ITeam[] }> => + request('/plugins/org/teams', { data }); + +export const get = (teamId: string): Promise => request(`/plugins/org/teams/${teamId}`); + +export const create = (data: { teams: Omit[] }): Promise => + request('/plugins/org/teams', { + method: 'post', + data, + }); + +export const update = (teamId: string, data: Omit): Promise => + request(`/plugins/org/teams/${teamId}`, { + method: 'put', + data, + }); + +export const remove = (teamId: string) => + request(`/plugins/org/teams/${teamId}`, { + method: 'delete', + }); + +export const listUsers = (teamId: string): Promise => request(`/plugins/org/teams/${teamId}/users`); + +export const updateUsers = (teamId: string, data: { userIds: string[] }): Promise => + request(`/plugins/org/teams/${teamId}/users`, { + method: 'put', + data, + }); diff --git a/config-ui/src/api/user/index.ts b/config-ui/src/api/user/index.ts new file mode 100644 index 00000000000..71a4d7deada --- /dev/null +++ b/config-ui/src/api/user/index.ts @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import type { IUser, IUserTeams } from '@/types'; +import { request } from '@/utils'; + +export const list = (data: Pagination & { email?: string }): Promise<{ count: number; users: IUser[] }> => + request('/plugins/org/users', { data }); + +export const get = (userId: string): Promise => request(`/plugins/org/users/${userId}`); + +export const create = (data: { users: Omit[] }): Promise => + request('/plugins/org/users', { + method: 'post', + data, + }); + +export const update = (userId: string, data: Omit): Promise => + request(`/plugins/org/users/${userId}`, { + method: 'put', + data, + }); + +export const remove = (userId: string) => + request(`/plugins/org/users/${userId}`, { + method: 'delete', + }); + +export const listTeams = (userId: string): Promise => request(`/plugins/org/users/${userId}/teams`); + +export const updateTeams = (userId: string, data: { teamIds: string[] }): Promise => + request(`/plugins/org/users/${userId}/teams`, { + method: 'put', + data, + }); diff --git a/config-ui/src/app/routrer.tsx b/config-ui/src/app/routrer.tsx index ac3113db789..df13b78b29c 100644 --- a/config-ui/src/app/routrer.tsx +++ b/config-ui/src/app/routrer.tsx @@ -28,6 +28,8 @@ import { Connection, ProjectHomePage, ProjectDetailPage, + TeamsHomePage, + UsersHomePage, BlueprintHomePage, BlueprintDetailPage, BlueprintConnectionDetailPage, @@ -74,6 +76,14 @@ export const router = createBrowserRouter([ path: 'projects/:pname/:unique', element: , }, + { + path: 'teams', + element: , + }, + { + path: 'users', + element: , + }, { path: 'connections', element: , diff --git a/config-ui/src/config/paths.ts b/config-ui/src/config/paths.ts index 72aa74dfb28..12280d771fa 100644 --- a/config-ui/src/config/paths.ts +++ b/config-ui/src/config/paths.ts @@ -34,4 +34,6 @@ export const PATHS = { `${PATH_PREFIX}/advanced/blueprints/${bid}/${plugin}-${connectionId}`, PIPELINES: () => `${PATH_PREFIX}/advanced/pipelines`, APIKEYS: () => `${PATH_PREFIX}/keys`, + TEAMS: () => `${PATH_PREFIX}/teams`, + USERS: () => `${PATH_PREFIX}/users`, }; diff --git a/config-ui/src/routes/index.ts b/config-ui/src/routes/index.ts index 025a0792fc1..712ed181526 100644 --- a/config-ui/src/routes/index.ts +++ b/config-ui/src/routes/index.ts @@ -26,3 +26,5 @@ export * from './not-found'; export * from './onboard'; export * from './pipeline'; export * from './project'; +export * from './teams'; +export * from './users'; diff --git a/config-ui/src/routes/layout/config.tsx b/config-ui/src/routes/layout/config.tsx index 48cf8159522..09c670027a6 100644 --- a/config-ui/src/routes/layout/config.tsx +++ b/config-ui/src/routes/layout/config.tsx @@ -19,6 +19,7 @@ import { AppstoreOutlined, ProjectOutlined, + TeamOutlined, ExperimentOutlined, KeyOutlined, DashboardOutlined, @@ -50,6 +51,21 @@ export const menuItems: MenuItem[] = [ label: 'Connections', icon: , }, + { + key: `${PATH_PREFIX}/organization`, + label: 'Organization', + icon: , + children: [ + { + key: `${PATH_PREFIX}/teams`, + label: 'Teams', + }, + { + key: `${PATH_PREFIX}/users`, + label: 'Users', + }, + ], + }, { key: `${PATH_PREFIX}/advanced`, label: 'Advanced', diff --git a/config-ui/src/routes/layout/layout.tsx b/config-ui/src/routes/layout/layout.tsx index adcffc988ed..5ff768d4e4d 100644 --- a/config-ui/src/routes/layout/layout.tsx +++ b/config-ui/src/routes/layout/layout.tsx @@ -22,6 +22,7 @@ import { Helmet } from 'react-helmet'; import { Layout as AntdLayout, Menu, Divider } from 'antd'; import { PageLoading, Logo, ExternalLink } from '@/components'; +import { PATHS } from '@/config'; import { init, selectError, selectStatus } from '@/features'; import { OnboardCard } from '@/routes/onboard/components'; import { useAppDispatch, useAppSelector } from '@/hooks'; @@ -32,11 +33,17 @@ const { Sider, Header, Content, Footer } = AntdLayout; const brandName = import.meta.env.DEVLAKE_BRAND_NAME ?? 'DevLake'; +type LayoutLoaderData = { + version: string; + plugins: string[]; + orgCapabilityAvailable: boolean; +}; + export const Layout = () => { const [openKeys, setOpenKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); - const { version, plugins } = useLoaderData() as { version: string; plugins: string[] }; + const { version, plugins, orgCapabilityAvailable } = useLoaderData() as LayoutLoaderData; const navigate = useNavigate(); const { pathname } = useLocation(); @@ -75,6 +82,37 @@ export const Layout = () => { return curMenuItem?.label ?? ''; }, [pathname]); + const filteredMenuItems = useMemo(() => { + if (orgCapabilityAvailable) { + return menuItems; + } + + const hiddenMenuKeys = new Set([PATHS.TEAMS(), PATHS.USERS()]); + + return menuItems + .map((item) => { + if (hiddenMenuKeys.has(item.key)) { + return null; + } + + if (!item.children) { + return item; + } + + const children = item.children.filter((child) => !hiddenMenuKeys.has(child.key)); + + if (children.length === 0) { + return null; + } + + return { + ...item, + children, + }; + }) + .filter((item): item is (typeof menuItems)[number] => item !== null); + }, [orgCapabilityAvailable]); + if (['idle', 'loading'].includes(status)) { return ; } @@ -102,7 +140,7 @@ export const Layout = () => { navigate(key)} diff --git a/config-ui/src/routes/layout/loader.ts b/config-ui/src/routes/layout/loader.ts index b52021b41c1..fae25924c0b 100644 --- a/config-ui/src/routes/layout/loader.ts +++ b/config-ui/src/routes/layout/loader.ts @@ -20,12 +20,48 @@ import { redirect } from 'react-router-dom'; import { intersection } from 'lodash'; import API from '@/api'; +import { PATHS } from '@/config'; import { getRegisterPlugins } from '@/plugins'; type Props = { request: Request; }; +const normalizePath = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path); + +const isOrgManagementPath = (path: string) => { + const normalizedPath = normalizePath(path); + return [normalizePath(PATHS.TEAMS()), normalizePath(PATHS.USERS())].includes(normalizedPath); +}; + +const parseEnabledPlugins = (plugins: string[]) => { + const envPlugins = import.meta.env.DEVLAKE_PLUGINS; + + if (typeof envPlugins !== 'string') { + return plugins; + } + + const enabledPlugins = envPlugins + .split(',') + .map((plugin: string) => plugin.trim()) + .filter(Boolean); + + if (!enabledPlugins.length) { + return plugins; + } + + return plugins.filter((plugin) => enabledPlugins.includes(plugin)); +}; + +const hasOrgApis = async () => { + const [teamsResult, usersResult] = await Promise.allSettled([ + API.team.list({ page: 1, pageSize: 1, grouped: false }), + API.user.list({ page: 1, pageSize: 1 }), + ]); + + return teamsResult.status === 'fulfilled' && usersResult.status === 'fulfilled'; +}; + export const layoutLoader = async ({ request }: Props) => { const onboard = await API.store.get('onboard'); @@ -33,21 +69,20 @@ export const layoutLoader = async ({ request }: Props) => { return redirect('/onboard'); } - let fePlugins = getRegisterPlugins(); + const fePlugins = parseEnabledPlugins(getRegisterPlugins()); const bePlugins = await API.plugin.list(); + const bePluginNames = bePlugins.map((it) => it.plugin); + const orgCapabilityAvailable = bePluginNames.includes('org') && (await hasOrgApis()); - try { - const envPlugins = import.meta.env.DEVLAKE_PLUGINS.split(',').filter(Boolean); - fePlugins = fePlugins.filter((plugin) => !envPlugins.length || envPlugins.includes(plugin)); - } catch (err) {} + if (!orgCapabilityAvailable && isOrgManagementPath(new URL(request.url).pathname)) { + return redirect(PATHS.CONNECTIONS()); + } const res = await API.version(request.signal); return { version: res.version, - plugins: intersection( - fePlugins, - bePlugins.map((it) => it.plugin), - ), + plugins: intersection(fePlugins, bePluginNames), + orgCapabilityAvailable, }; }; diff --git a/config-ui/src/routes/teams/home/index.tsx b/config-ui/src/routes/teams/home/index.tsx new file mode 100644 index 00000000000..8da3ab24428 --- /dev/null +++ b/config-ui/src/routes/teams/home/index.tsx @@ -0,0 +1,381 @@ + + +import { useState, useMemo, useRef, useEffect } from 'react'; +import { PlusOutlined, EditOutlined, DeleteOutlined, UsergroupAddOutlined } from '@ant-design/icons'; +import { Flex, Table, Button, Modal, Input, InputNumber, Space, Popconfirm, Select, Badge } from 'antd'; + +import API from '@/api'; +import { PageHeader, Block } from '@/components'; +import { PATHS } from '@/config'; +import { useRefreshData } from '@/hooks'; +import { operator } from '@/utils'; +import type { ITeam, IUser } from '@/types'; + +const emptyForm = { name: '', alias: '', parentId: '', sortingIndex: 0 }; +const PARENT_TEAM_OPTIONS_PAGE_SIZE = 10000; +const TEAM_USERS_PAGE_SIZE = 10000; + +export const TeamsHomePage = () => { + const [version, setVersion] = useState(1); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [expandedRowKeys, setExpandedRowKeys] = useState>([]); + const [open, setOpen] = useState(false); + const [editingTeam, setEditingTeam] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [usersModalOpen, setUsersModalOpen] = useState(false); + const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [savingTeamUsers, setSavingTeamUsers] = useState(false); + const [usersSearchKeyword, setUsersSearchKeyword] = useState(''); + + const debounceRef = useRef(null); + + const { ready, data } = useRefreshData( + () => API.team.list({ page, pageSize, grouped: true, ...(searchKeyword.trim() && { name: searchKeyword.trim() }) }), + [version, page, pageSize, searchKeyword], + ); + + const { ready: parentOptionsReady, data: parentOptionsData } = useRefreshData( + () => API.team.list({ page: 1, pageSize: PARENT_TEAM_OPTIONS_PAGE_SIZE, grouped: false }), + [version], + ); + + const { ready: usersReady, data: usersData } = useRefreshData( + () => (usersModalOpen ? API.user.list({ page: 1, pageSize: TEAM_USERS_PAGE_SIZE }) : Promise.resolve({ count: 0, users: [] })), + [usersModalOpen], + ); + + const { ready: selectedTeamUsersReady, data: selectedTeamUsersData } = useRefreshData( + () => { + if (!usersModalOpen || !selectedTeam?.id) { + return Promise.resolve({ teamId: '', userIds: [], count: 0 }); + } + return API.team.listUsers(selectedTeam.id); + }, + [usersModalOpen, selectedTeam?.id], + ); + + const [dataSource, total] = useMemo(() => [data?.teams ?? [], data?.count ?? 0], [data]); + + const parentTeamOptions = useMemo( + () => + (parentOptionsData?.teams ?? []) + .filter((team) => team.id !== editingTeam?.id) + .sort((a, b) => { + if (a.sortingIndex !== b.sortingIndex) { + return a.sortingIndex - b.sortingIndex; + } + return a.name.localeCompare(b.name); + }) + .map((team) => ({ + value: team.id, + label: `${team.name} (${team.id})`, + })), + [parentOptionsData, editingTeam], + ); + + const usersDataSource = useMemo(() => { + const keyword = usersSearchKeyword.trim().toLowerCase(); + const users = usersData?.users ?? []; + + if (!keyword) { + return users; + } + + return users.filter((user) => { + const name = user.name?.toLowerCase() ?? ''; + const email = user.email?.toLowerCase() ?? ''; + return name.includes(keyword) || email.includes(keyword); + }); + }, [usersSearchKeyword, usersData]); + + useEffect(() => { + if (!usersModalOpen) { + return; + } + setSelectedUserIds(selectedTeamUsersData?.userIds ?? []); + }, [usersModalOpen, selectedTeamUsersData]); + + const refresh = () => { + setExpandedRowKeys([]); + setVersion((v) => v + 1); + }; + + const handleShowCreate = () => { + setEditingTeam(null); + setForm(emptyForm); + setOpen(true); + }; + + const handleShowEdit = (team: ITeam) => { + setEditingTeam(team); + setForm({ name: team.name, alias: team.alias, parentId: team.parentId, sortingIndex: team.sortingIndex }); + setOpen(true); + }; + + const handleHideDialog = () => { + setOpen(false); + setEditingTeam(null); + setForm(emptyForm); + }; + + const handleShowUsersModal = (team: ITeam) => { + setSelectedTeam(team); + setUsersSearchKeyword(''); + setUsersModalOpen(true); + }; + + const handleHideUsersModal = () => { + setUsersModalOpen(false); + setSelectedTeam(null); + setSelectedUserIds([]); + setUsersSearchKeyword(''); + }; + + const handleSave = async () => { + const [success] = await operator( + async () => { + if (editingTeam) { + return API.team.update(editingTeam.id, form); + } + return API.team.create({ teams: [form] }); + }, + { setOperating: setSaving }, + ); + + if (success) { + handleHideDialog(); + refresh(); + } + }; + + const handleDelete = async (teamId: string) => { + const [success] = await operator(() => API.team.remove(teamId)); + if (success) { + refresh(); + } + }; + + const handleSaveTeamUsers = async () => { + if (!selectedTeam) { + return; + } + + const [success] = await operator( + () => API.team.updateUsers(selectedTeam.id, { userIds: selectedUserIds }), + { setOperating: setSavingTeamUsers }, + ); + + if (success) { + handleHideUsersModal(); + refresh(); + } + }; + + const handleSearch = (value: string) => { + setInputValue(value); + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + setSearchKeyword(value.trim()); + setPage(1); + refresh(); + }, 500); + }; + + const handleExpand = (expanded: boolean, record: ITeam) => { + if (!record.children?.length) { + return; + } + setExpandedRowKeys(expanded ? [record.id] : []); + }; + + return ( + + + handleSearch(e.target.value)} + /> + + + !!record.children?.length, + }} + columns={[ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Alias', + dataIndex: 'alias', + key: 'alias', + render: (val: string) => val || '-', + }, + { + title: 'Users', + dataIndex: 'userCount', + key: 'userCount', + width: 160, + align: 'center', + render: (val: number | undefined, record: ITeam) => ( + +
val || '-', + }, + ]} + dataSource={usersDataSource} + rowSelection={{ + selectedRowKeys: selectedUserIds, + onChange: (selectedRowKeys) => setSelectedUserIds(selectedRowKeys as string[]), + }} + pagination={{ + pageSize: 8, + showSizeChanger: false, + }} + /> + + + + setForm((f) => ({ ...f, name: e.target.value }))} + /> + + + setForm((f) => ({ ...f, alias: e.target.value }))} + /> + + + handleSearch(e.target.value)} + /> + + + + {showPipelineInfo && ( + + + {triggeredPipelineId} + + {pipelineData ? : 'Loading...'} + + + {pipelineData ? `${pipelineData.finishedTasks}/${pipelineData.totalTasks}` : '-'} + + {formatTime(pipelineData?.beganAt ?? null)} + {formatTime(pipelineData?.finishedAt ?? null)} + + {pipelineData ? ( + + ) : ( + '-' + )} + + {pipelineData?.stage ?? '-'} + + {pipelineData?.message || '-'} + + + + )} +
setExpandedRowKeys(expanded ? [record.id] : []), + rowExpandable: () => true, + expandedRowRender: renderUserAccountSourcesRow, + }} + columns={[ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + { + title: 'Email', + dataIndex: 'email', + key: 'email', + render: (val: string) => val || '-', + }, + { + title: 'Accounts', + dataIndex: 'accountCount', + key: 'accountCount', + width: 120, + align: 'center', + render: (val: number | undefined, record: IUser) => val ?? record.accountSources?.length ?? 0, + }, + { + title: 'Teams', + dataIndex: 'teamCount', + key: 'teamCount', + width: 130, + align: 'center', + render: (val: number | undefined, record: IUser) => { + const count = val ?? record.teamNames?.length ?? 0; + const teams = record.teamNames ?? []; + const tooltipTitle = + teams.length > 0 ? ( +
+ {teams.map((teamName, index) => ( +
{teamName}
+ ))} +
+ ) : ( + 'No groups assigned' + ); + + return ( + + +
val || '-', + }, + ]} + dataSource={teamsDataSource} + rowSelection={{ + selectedRowKeys: selectedTeamIds, + onChange: (selectedRowKeys) => setSelectedTeamIds(selectedRowKeys as string[]), + }} + pagination={{ + pageSize: 8, + showSizeChanger: false, + }} + /> + + + + setForm((f) => ({ ...f, name: e.target.value }))} + /> + + + setForm((f) => ({ ...f, email: e.target.value }))} + /> + + + + ); +}; diff --git a/config-ui/src/routes/users/index.ts b/config-ui/src/routes/users/index.ts new file mode 100644 index 00000000000..e20557b3068 --- /dev/null +++ b/config-ui/src/routes/users/index.ts @@ -0,0 +1 @@ +export * from './home'; diff --git a/config-ui/src/types/index.ts b/config-ui/src/types/index.ts index 1f99f11133e..713de59aa2c 100644 --- a/config-ui/src/types/index.ts +++ b/config-ui/src/types/index.ts @@ -26,4 +26,6 @@ export * from './project'; export * from './scope-config'; export * from './status'; export * from './task'; +export * from './team'; +export * from './user'; export * from './webhook'; diff --git a/config-ui/src/types/team.ts b/config-ui/src/types/team.ts new file mode 100644 index 00000000000..b1420bb4243 --- /dev/null +++ b/config-ui/src/types/team.ts @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export interface ITeam { + id: string; + name: string; + alias: string; + parentId: string; + sortingIndex: number; + userCount?: number; + children?: ITeam[]; +} + +export interface ITeamUsers { + teamId: string; + userIds: string[]; + count: number; +} diff --git a/config-ui/src/types/user.ts b/config-ui/src/types/user.ts new file mode 100644 index 00000000000..1891db64cfb --- /dev/null +++ b/config-ui/src/types/user.ts @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export interface IUser { + id: string; + name: string; + email: string; + teamIds: string; + teamCount?: number; + teamNames?: string[]; + accountCount?: number; + accountSources?: string[]; +} + +export interface IUserTeams { + userId: string; + teamIds: string[]; + teamNames: string[]; + count: number; +}