diff --git a/frontend/src/components/home/ProjectsNavigationItem.vue b/frontend/src/components/home/ProjectsNavigationItem.vue index c69cf7202..68d0757cb 100644 --- a/frontend/src/components/home/ProjectsNavigationItem.vue +++ b/frontend/src/components/home/ProjectsNavigationItem.vue @@ -25,7 +25,7 @@ />
@@ -48,7 +48,7 @@ {{ getProjectTitle(project) }} @@ -94,6 +95,7 @@ import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdow import {getProjectTitle} from '@/helpers/getProjectTitle' import ColorBubble from '@/components/misc/ColorBubble.vue' import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue' +import {RIGHTS} from '@/constants/rights' const props = defineProps<{ project: IProject, diff --git a/frontend/src/components/project/ProjectSettingsDropdown.vue b/frontend/src/components/project/ProjectSettingsDropdown.vue index 4ce76144f..c25e28932 100644 --- a/frontend/src/components/project/ProjectSettingsDropdown.vue +++ b/frontend/src/components/project/ProjectSettingsDropdown.vue @@ -109,6 +109,7 @@ {{ $t('menu.createProject') }} { let page = 1 try { do { - const newProjects = await projectService.getAll({}, {is_archived: true}, page) as IProject[] + const newProjects = await projectService.getAll({}, {is_archived: true, expand: 'rights'}, page) as IProject[] loadedProjects.push(...newProjects) page++ } while (page <= projectService.totalPages) diff --git a/frontend/src/views/project/settings/ProjectSettingsEdit.vue b/frontend/src/views/project/settings/ProjectSettingsEdit.vue index 6ff325656..4243ba2f9 100644 --- a/frontend/src/views/project/settings/ProjectSettingsEdit.vue +++ b/frontend/src/views/project/settings/ProjectSettingsEdit.vue @@ -3,7 +3,7 @@ :title="$t('project.edit.header')" primary-icon="" :primary-label="$t('misc.save')" - :tertiary="$t('misc.delete')" + :tertiary="project.maxRight === RIGHTS.ADMIN ? $t('misc.delete') : undefined" @primary="save" @tertiary="$router.push({ name: 'project.settings.delete', params: { id: projectId } })" > @@ -99,6 +99,7 @@ import {useProjectStore} from '@/stores/projects' import {useProject} from '@/stores/projects' import {useTitle} from '@/composables/useTitle' +import {RIGHTS} from '@/constants/rights' const props = defineProps<{ projectId: IProject['id'], diff --git a/pkg/models/project.go b/pkg/models/project.go index 1592d52c3..f729d1475 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -77,6 +77,9 @@ type Project struct { Views []*ProjectView `xorm:"-" json:"views"` + Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"` + MaxRight Right `xorm:"-" json:"max_right"` + // A timestamp when this project was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this project was last updated. You cannot change this value. @@ -86,6 +89,10 @@ type Project struct { web.Rights `xorm:"-" json:"-"` } +type ProjectExpandable string + +const ProjectExpandableRights = `rights` + type ProjectWithTasksAndBuckets struct { Project ChildProjects []*ProjectWithTasksAndBuckets `xorm:"-" json:"child_projects"` @@ -161,6 +168,7 @@ var FavoritesPseudoProject = Project{ // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param s query string false "Search projects by title." // @Param is_archived query bool false "If true, also returns all archived projects." +// @Param expand query string false "If set to `rights`, Vikunja will return the max right the current user has on this project. You can currently only set this to `rights`." // @Security JWTKeyAuth // @Success 200 {array} models.Project "The projects" // @Failure 403 {object} web.HTTPError "The user does not have access to the project" @@ -219,6 +227,17 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int, return } + if p.Expand == ProjectExpandableRights { + err = addMaxRightToProjects(s, prs, a) + if err != nil { + return + } + } else { + for _, pr := range prs { + pr.MaxRight = RightUnknown + } + } + ////////////////////////// // Putting it all together @@ -719,6 +738,31 @@ func addProjectDetails(s *xorm.Session, projects []*Project, a web.Auth) (err er return } +func addMaxRightToProjects(s *xorm.Session, projects []*Project, a web.Auth) (err error) { + projectIDs := make([]int64, 0, len(projects)) + for _, project := range projects { + if getSavedFilterIDFromProjectID(project.ID) > 0 { + project.MaxRight = RightAdmin + continue + } + projectIDs = append(projectIDs, project.ID) + } + + rights, err := checkRightsForProjects(s, a, projectIDs) + if err != nil { + return err + } + + for _, project := range projects { + right, has := rights[project.ID] + if has { + project.MaxRight = right.MaxRight + } + } + + return +} + // CheckIsArchived returns an ErrProjectIsArchived if the project or any of its parent projects is archived. func (p *Project) CheckIsArchived(s *xorm.Session) (err error) { if p.ParentProjectID > 0 { diff --git a/pkg/models/project_rights.go b/pkg/models/project_rights.go index 09785389e..2093d0c97 100644 --- a/pkg/models/project_rights.go +++ b/pkg/models/project_rights.go @@ -18,10 +18,11 @@ package models import ( "errors" + "strings" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" - "xorm.io/builder" + "xorm.io/xorm" ) @@ -105,9 +106,6 @@ func (p *Project) CanRead(s *xorm.Session, a web.Auth) (bool, int, error) { (shareAuth.Right == RightRead || shareAuth.Right == RightWrite || shareAuth.Right == RightAdmin), int(shareAuth.Right), nil } - if p.isOwner(&user.User{ID: a.GetID()}) { - return true, int(RightAdmin), nil - } return p.checkRight(s, a, RightRead, RightWrite, RightAdmin) } @@ -211,66 +209,85 @@ func (p *Project) isOwner(u *user.User) bool { // Checks n different rights for any given user func (p *Project) checkRight(s *xorm.Session, a web.Auth, rights ...Right) (bool, int, error) { + projectRights, err := checkRightsForProjects(s, a, []int64{p.ID}) + if err != nil { + return false, 0, err + } + right, has := projectRights[p.ID] + if !has { + return false, 0, nil + } - var conds []builder.Cond for _, r := range rights { - // User conditions - // If the project was shared directly with the user and the user has the right - conds = append(conds, builder.And( - builder.Eq{"ul.user_id": a.GetID()}, - builder.Eq{"ul.right": r}, - )) - - // Team rights - // If the project was shared directly with the team and the team has the right - conds = append(conds, builder.And( - builder.Eq{"tm2.user_id": a.GetID()}, - builder.Eq{"tl.right": r}, - )) - } - - type allProjectRights struct { - UserProject *ProjectUser `xorm:"extends"` - TeamProject *TeamProject `xorm:"extends"` - } - - r := &allProjectRights{} - var maxRight = 0 - exists, err := s. - Select("p.*, ul.right, tl.right"). - Table("projects"). - Alias("p"). - // User stuff - Join("LEFT", []string{"users_projects", "ul"}, "ul.project_id = p.id"). - // Team stuff - Join("LEFT", []string{"team_projects", "tl"}, "p.id = tl.project_id"). - Join("LEFT", []string{"team_members", "tm2"}, "tm2.team_id = tl.team_id"). - // The actual condition - Where(builder.And( - builder.Or( - conds..., - ), - builder.Eq{"p.id": p.ID}, - )). - Get(r) - - // If there's noting shared for this project, and it has a parent, go up the tree - if !exists && p.ParentProjectID > 0 { - parent, err := GetProjectSimpleByID(s, p.ParentProjectID) - if err != nil { - return false, 0, err + if r == right.MaxRight { + return true, int(right.MaxRight), nil } - - return parent.checkRight(s, a, rights...) } - // Figure out the max right and return it - if int(r.UserProject.Right) > maxRight { - maxRight = int(r.UserProject.Right) - } - if int(r.TeamProject.Right) > maxRight { - maxRight = int(r.TeamProject.Right) - } - - return exists, maxRight, err + return false, 0, nil +} + +type projectRight struct { + ID int64 `xorm:"pk autoincr"` + MaxRight Right +} + +func checkRightsForProjects(s *xorm.Session, a web.Auth, projectIDs []int64) (projectRightMap map[int64]*projectRight, err error) { + projectRightMap = make(map[int64]*projectRight) + whereIDIn := strings.Repeat("?,", len(projectIDs))[:len(projectIDs)*2-1] + args := []interface{}{ + a.GetID(), + a.GetID(), + a.GetID(), + int64(0), + } + for _, id := range projectIDs { + args = append(args, id) + } + + err = s.SQL(`WITH RECURSIVE + all_projects AS (SELECT p.id, + p.parent_project_id, + CASE + WHEN p.owner_id = 1 THEN 2 + WHEN COALESCE(ul.right, 0) > COALESCE(tl.right, 0) THEN ul.right + ELSE COALESCE(tl.right, 0) + END AS initial_right + FROM projects p + LEFT JOIN team_projects tl ON tl.project_id = p.id + LEFT JOIN team_members tm2 ON tm2.team_id = tl.team_id + LEFT JOIN users_projects ul ON ul.project_id = p.id + WHERE (tm2.user_id = ? OR ul.user_id = ? OR p.owner_id = ?) + AND (p.parent_project_id IS NULL OR p.parent_project_id = ? OR + ((tm2.user_id IS NOT NULL OR ul.user_id IS NOT NULL) AND + p.parent_project_id IS NOT NULL)) + AND p.id in (`+whereIDIn+`) + GROUP BY p.id + + UNION ALL + + SELECT p.id, p.parent_project_id, ap.initial_right + FROM projects p + INNER JOIN all_projects ap ON p.parent_project_id = ap.id), + + project_max_rights AS (SELECT id, MAX(initial_right) AS max_right + FROM all_projects + GROUP BY id), + + inherited_rights AS (SELECT ap.id, + CASE + WHEN COALESCE(pmr.max_right, 0) > COALESCE(parent.max_right, 0) + THEN COALESCE(pmr.max_right, 0) + ELSE COALESCE(parent.max_right, 0) + END AS inherited_right + FROM all_projects ap + LEFT JOIN project_max_rights pmr ON ap.id = pmr.id + LEFT JOIN project_max_rights parent ON ap.parent_project_id = parent.id) + +SELECT DISTINCT ap.id, + ir.inherited_right AS max_right +FROM all_projects ap + LEFT JOIN all_projects np ON ap.parent_project_id = np.id + LEFT JOIN inherited_rights ir ON ap.id = ir.id`, args...).Find(&projectRightMap) + return } diff --git a/pkg/models/rights.go b/pkg/models/rights.go index b25aedeca..64df592f0 100644 --- a/pkg/models/rights.go +++ b/pkg/models/rights.go @@ -16,18 +16,20 @@ package models +import ( + "encoding/json" + "fmt" + "strconv" +) + // Right defines the rights users/teams can have for projects type Right int // define unknown right const ( RightUnknown = -1 -) - -// Enumerate all the team rights -const ( // Can read projects in a - RightRead Right = iota + RightRead Right = iota - 1 // Can write in a like projects and tasks. Cannot create new projects. RightWrite // Can manage a project, can do everything @@ -41,3 +43,33 @@ func (r Right) isValid() error { return nil } + +// MarshalJSON marshals the enum as a quoted json string +func (r Right) MarshalJSON() ([]byte, error) { + if r == RightUnknown { + return []byte(`null`), nil + } + return []byte(strconv.Itoa(int(r))), nil +} + +// UnmarshalJSON unmarshals a quoted json string to the enum value +func (r *Right) UnmarshalJSON(data []byte) error { + var s int + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + switch s { + case -1: + *r = RightUnknown + case 0: + *r = RightRead + case 1: + *r = RightWrite + case 2: + *r = RightAdmin + default: + return fmt.Errorf("invalid Right %q", s) + } + return nil +}