From 6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Fri, 20 Jan 2023 19:42:33 +0800
Subject: [PATCH] Support org/user level projects (#22235)

Fix #13405

<img width="1151" alt="image"
src="https://user-images.githubusercontent.com/81045/209442911-7baa3924-c389-47b6-b63b-a740803e640e.png">

Co-authored-by: 6543 <6543@obermui.de>
---
 models/fixtures/project.yml                   |   9 +
 models/fixtures/project_board.yml             |   8 +
 models/issues/issue.go                        |   2 +-
 models/issues/issue_project.go                |  10 +-
 models/organization/team.go                   |  65 --
 models/organization/team_list.go              | 128 ++++
 models/organization/team_user.go              |  20 -
 models/project/project.go                     |  94 ++-
 models/project/project_test.go                |   6 +-
 modules/context/org.go                        |  28 +
 routers/web/org/main_test.go                  |  17 +
 routers/web/org/projects.go                   | 670 ++++++++++++++++++
 routers/web/org/projects_test.go              |  28 +
 routers/web/repo/issue.go                     |  33 +-
 routers/web/repo/projects.go                  |  48 +-
 routers/web/shared/user/header.go             |  14 +
 routers/web/user/package.go                   |  17 +-
 routers/web/user/profile.go                   |   2 +-
 routers/web/web.go                            |  42 +-
 services/context/user.go                      |   9 +
 templates/org/menu.tmpl                       |   3 +
 templates/org/projects/list.tmpl              |   6 +
 templates/org/projects/new.tmpl               |   6 +
 templates/org/projects/view.tmpl              |   6 +
 templates/projects/list.tmpl                  |  98 +++
 templates/projects/new.tmpl                   |  66 ++
 templates/projects/view.tmpl                  | 279 ++++++++
 .../repo/issue/view_content/sidebar.tmpl      |  12 +-
 templates/user/overview/header.tmpl           |   3 +
 templates/user/profile.tmpl                   |   3 +
 30 files changed, 1556 insertions(+), 176 deletions(-)
 create mode 100644 models/organization/team_list.go
 create mode 100644 routers/web/org/main_test.go
 create mode 100644 routers/web/org/projects.go
 create mode 100644 routers/web/org/projects_test.go
 create mode 100644 routers/web/shared/user/header.go
 create mode 100644 templates/org/projects/list.tmpl
 create mode 100644 templates/org/projects/new.tmpl
 create mode 100644 templates/org/projects/view.tmpl
 create mode 100644 templates/projects/list.tmpl
 create mode 100644 templates/projects/new.tmpl
 create mode 100644 templates/projects/view.tmpl

diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml
index 3d42597c5e..f38b5344bb 100644
--- a/models/fixtures/project.yml
+++ b/models/fixtures/project.yml
@@ -24,3 +24,12 @@
   creator_id: 5
   board_type: 1
   type: 2
+
+-
+  id: 4
+  title: project on user2
+  owner_id: 2
+  is_closed: false
+  creator_id: 2
+  board_type: 1
+  type: 2
diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml
index 9e06e8c239..dc4f9cf565 100644
--- a/models/fixtures/project_board.yml
+++ b/models/fixtures/project_board.yml
@@ -21,3 +21,11 @@
   creator_id: 2
   created_unix: 1588117528
   updated_unix: 1588117528
+
+-
+  id: 4
+  project_id: 4
+  title: Done
+  creator_id: 2
+  created_unix: 1588117528
+  updated_unix: 1588117528
diff --git a/models/issues/issue.go b/models/issues/issue.go
index 4a8ab06824..dc9e5c5acd 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -1098,7 +1098,7 @@ func GetIssueWithAttrsByID(id int64) (*Issue, error) {
 }
 
 // GetIssuesByIDs return issues with the given IDs.
-func GetIssuesByIDs(ctx context.Context, issueIDs []int64) ([]*Issue, error) {
+func GetIssuesByIDs(ctx context.Context, issueIDs []int64) (IssueList, error) {
 	issues := make([]*Issue, 0, 10)
 	return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues)
 }
diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
index 8e559f13c9..c9f4c9f533 100644
--- a/models/issues/issue_project.go
+++ b/models/issues/issue_project.go
@@ -125,13 +125,17 @@ func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64
 func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
 	oldProjectID := issue.projectID(ctx)
 
+	if err := issue.LoadRepo(ctx); err != nil {
+		return err
+	}
+
 	// Only check if we add a new project and not remove it.
 	if newProjectID > 0 {
 		newProject, err := project_model.GetProjectByID(ctx, newProjectID)
 		if err != nil {
 			return err
 		}
-		if newProject.RepoID != issue.RepoID {
+		if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID {
 			return fmt.Errorf("issue's repository is not the same as project's repository")
 		}
 	}
@@ -140,10 +144,6 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U
 		return err
 	}
 
-	if err := issue.LoadRepo(ctx); err != nil {
-		return err
-	}
-
 	if oldProjectID > 0 || newProjectID > 0 {
 		if _, err := CreateComment(ctx, &CreateCommentOptions{
 			Type:         CommentTypeProject,
diff --git a/models/organization/team.go b/models/organization/team.go
index 55d3f17276..0c2577dab1 100644
--- a/models/organization/team.go
+++ b/models/organization/team.go
@@ -16,8 +16,6 @@ import (
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/util"
-
-	"xorm.io/builder"
 )
 
 // ___________
@@ -96,59 +94,6 @@ func init() {
 	db.RegisterModel(new(TeamInvite))
 }
 
-// SearchTeamOptions holds the search options
-type SearchTeamOptions struct {
-	db.ListOptions
-	UserID      int64
-	Keyword     string
-	OrgID       int64
-	IncludeDesc bool
-}
-
-func (opts *SearchTeamOptions) toCond() builder.Cond {
-	cond := builder.NewCond()
-
-	if len(opts.Keyword) > 0 {
-		lowerKeyword := strings.ToLower(opts.Keyword)
-		var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword}
-		if opts.IncludeDesc {
-			keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword})
-		}
-		cond = cond.And(keywordCond)
-	}
-
-	if opts.OrgID > 0 {
-		cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
-	}
-
-	if opts.UserID > 0 {
-		cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
-	}
-
-	return cond
-}
-
-// SearchTeam search for teams. Caller is responsible to check permissions.
-func SearchTeam(opts *SearchTeamOptions) ([]*Team, int64, error) {
-	sess := db.GetEngine(db.DefaultContext)
-
-	opts.SetDefaultValues()
-	cond := opts.toCond()
-
-	if opts.UserID > 0 {
-		sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
-	}
-	sess = db.SetSessionPagination(sess, opts)
-
-	teams := make([]*Team, 0, opts.PageSize)
-	count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	return teams, count, nil
-}
-
 // ColorFormat provides a basic color format for a Team
 func (t *Team) ColorFormat(s fmt.State) {
 	if t == nil {
@@ -335,16 +280,6 @@ func GetTeamNamesByID(teamIDs []int64) ([]string, error) {
 	return teamNames, err
 }
 
-// GetRepoTeams gets the list of teams that has access to the repository
-func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams []*Team, err error) {
-	return teams, db.GetEngine(ctx).
-		Join("INNER", "team_repo", "team_repo.team_id = team.id").
-		Where("team.org_id = ?", repo.OwnerID).
-		And("team_repo.repo_id=?", repo.ID).
-		OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END").
-		Find(&teams)
-}
-
 // IncrTeamRepoNum increases the number of repos for the given team by 1
 func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
 	_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
diff --git a/models/organization/team_list.go b/models/organization/team_list.go
new file mode 100644
index 0000000000..5d3bd555cc
--- /dev/null
+++ b/models/organization/team_list.go
@@ -0,0 +1,128 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package organization
+
+import (
+	"context"
+	"strings"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/perm"
+	repo_model "code.gitea.io/gitea/models/repo"
+	"code.gitea.io/gitea/models/unit"
+
+	"xorm.io/builder"
+)
+
+type TeamList []*Team
+
+func (t TeamList) LoadUnits(ctx context.Context) error {
+	for _, team := range t {
+		if err := team.getUnits(ctx); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode {
+	maxAccess := perm.AccessModeNone
+	for _, team := range t {
+		if team.IsOwnerTeam() {
+			return perm.AccessModeOwner
+		}
+		for _, teamUnit := range team.Units {
+			if teamUnit.Type != tp {
+				continue
+			}
+			if teamUnit.AccessMode > maxAccess {
+				maxAccess = teamUnit.AccessMode
+			}
+		}
+	}
+	return maxAccess
+}
+
+// SearchTeamOptions holds the search options
+type SearchTeamOptions struct {
+	db.ListOptions
+	UserID      int64
+	Keyword     string
+	OrgID       int64
+	IncludeDesc bool
+}
+
+func (opts *SearchTeamOptions) toCond() builder.Cond {
+	cond := builder.NewCond()
+
+	if len(opts.Keyword) > 0 {
+		lowerKeyword := strings.ToLower(opts.Keyword)
+		var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword}
+		if opts.IncludeDesc {
+			keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword})
+		}
+		cond = cond.And(keywordCond)
+	}
+
+	if opts.OrgID > 0 {
+		cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
+	}
+
+	if opts.UserID > 0 {
+		cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
+	}
+
+	return cond
+}
+
+// SearchTeam search for teams. Caller is responsible to check permissions.
+func SearchTeam(opts *SearchTeamOptions) (TeamList, int64, error) {
+	sess := db.GetEngine(db.DefaultContext)
+
+	opts.SetDefaultValues()
+	cond := opts.toCond()
+
+	if opts.UserID > 0 {
+		sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
+	}
+	sess = db.SetSessionPagination(sess, opts)
+
+	teams := make([]*Team, 0, opts.PageSize)
+	count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return teams, count, nil
+}
+
+// GetRepoTeams gets the list of teams that has access to the repository
+func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamList, err error) {
+	return teams, db.GetEngine(ctx).
+		Join("INNER", "team_repo", "team_repo.team_id = team.id").
+		Where("team.org_id = ?", repo.OwnerID).
+		And("team_repo.repo_id=?", repo.ID).
+		OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END").
+		Find(&teams)
+}
+
+// GetUserOrgTeams returns all teams that user belongs to in given organization.
+func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) {
+	return teams, db.GetEngine(ctx).
+		Join("INNER", "team_user", "team_user.team_id = team.id").
+		Where("team.org_id = ?", orgID).
+		And("team_user.uid=?", userID).
+		Find(&teams)
+}
+
+// GetUserRepoTeams returns user repo's teams
+func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) {
+	return teams, db.GetEngine(ctx).
+		Join("INNER", "team_user", "team_user.team_id = team.id").
+		Join("INNER", "team_repo", "team_repo.team_id = team.id").
+		Where("team.org_id = ?", orgID).
+		And("team_user.uid=?", userID).
+		And("team_repo.repo_id=?", repoID).
+		Find(&teams)
+}
diff --git a/models/organization/team_user.go b/models/organization/team_user.go
index 7a024f1c6d..816daf3d34 100644
--- a/models/organization/team_user.go
+++ b/models/organization/team_user.go
@@ -72,26 +72,6 @@ func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_mo
 	return members, nil
 }
 
-// GetUserOrgTeams returns all teams that user belongs to in given organization.
-func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams []*Team, err error) {
-	return teams, db.GetEngine(ctx).
-		Join("INNER", "team_user", "team_user.team_id = team.id").
-		Where("team.org_id = ?", orgID).
-		And("team_user.uid=?", userID).
-		Find(&teams)
-}
-
-// GetUserRepoTeams returns user repo's teams
-func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams []*Team, err error) {
-	return teams, db.GetEngine(ctx).
-		Join("INNER", "team_user", "team_user.team_id = team.id").
-		Join("INNER", "team_repo", "team_repo.team_id = team.id").
-		Where("team.org_id = ?", orgID).
-		And("team_user.uid=?", userID).
-		And("team_repo.repo_id=?", repoID).
-		Find(&teams)
-}
-
 // IsUserInTeams returns if a user in some teams
 func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) {
 	return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser))
diff --git a/models/project/project.go b/models/project/project.go
index f432d0bc4c..8bac9115ba 100644
--- a/models/project/project.go
+++ b/models/project/project.go
@@ -8,6 +8,9 @@ import (
 	"fmt"
 
 	"code.gitea.io/gitea/models/db"
+	repo_model "code.gitea.io/gitea/models/repo"
+	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
@@ -78,12 +81,15 @@ func (err ErrProjectBoardNotExist) Unwrap() error {
 
 // Project represents a project board
 type Project struct {
-	ID          int64  `xorm:"pk autoincr"`
-	Title       string `xorm:"INDEX NOT NULL"`
-	Description string `xorm:"TEXT"`
-	RepoID      int64  `xorm:"INDEX"`
-	CreatorID   int64  `xorm:"NOT NULL"`
-	IsClosed    bool   `xorm:"INDEX"`
+	ID          int64                  `xorm:"pk autoincr"`
+	Title       string                 `xorm:"INDEX NOT NULL"`
+	Description string                 `xorm:"TEXT"`
+	OwnerID     int64                  `xorm:"INDEX"`
+	Owner       *user_model.User       `xorm:"-"`
+	RepoID      int64                  `xorm:"INDEX"`
+	Repo        *repo_model.Repository `xorm:"-"`
+	CreatorID   int64                  `xorm:"NOT NULL"`
+	IsClosed    bool                   `xorm:"INDEX"`
 	BoardType   BoardType
 	Type        Type
 
@@ -94,6 +100,46 @@ type Project struct {
 	ClosedDateUnix timeutil.TimeStamp
 }
 
+func (p *Project) LoadOwner(ctx context.Context) (err error) {
+	if p.Owner != nil {
+		return nil
+	}
+	p.Owner, err = user_model.GetUserByID(ctx, p.OwnerID)
+	return err
+}
+
+func (p *Project) LoadRepo(ctx context.Context) (err error) {
+	if p.RepoID == 0 || p.Repo != nil {
+		return nil
+	}
+	p.Repo, err = repo_model.GetRepositoryByID(ctx, p.RepoID)
+	return err
+}
+
+func (p *Project) Link() string {
+	if p.OwnerID > 0 {
+		err := p.LoadOwner(db.DefaultContext)
+		if err != nil {
+			log.Error("LoadOwner: %v", err)
+			return ""
+		}
+		return fmt.Sprintf("/%s/-/projects/%d", p.Owner.Name, p.ID)
+	}
+	if p.RepoID > 0 {
+		err := p.LoadRepo(db.DefaultContext)
+		if err != nil {
+			log.Error("LoadRepo: %v", err)
+			return ""
+		}
+		return fmt.Sprintf("/%s/projects/%d", p.Repo.RepoPath(), p.ID)
+	}
+	return ""
+}
+
+func (p *Project) IsOrganizationProject() bool {
+	return p.Type == TypeOrganization
+}
+
 func init() {
 	db.RegisterModel(new(Project))
 }
@@ -110,7 +156,7 @@ func GetProjectsConfig() []ProjectsConfig {
 // IsTypeValid checks if a project type is valid
 func IsTypeValid(p Type) bool {
 	switch p {
-	case TypeRepository:
+	case TypeRepository, TypeOrganization:
 		return true
 	default:
 		return false
@@ -119,6 +165,7 @@ func IsTypeValid(p Type) bool {
 
 // SearchOptions are options for GetProjects
 type SearchOptions struct {
+	OwnerID  int64
 	RepoID   int64
 	Page     int
 	IsClosed util.OptionalBool
@@ -126,12 +173,11 @@ type SearchOptions struct {
 	Type     Type
 }
 
-// GetProjects returns a list of all projects that have been created in the repository
-func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) {
-	e := db.GetEngine(ctx)
-	projects := make([]*Project, 0, setting.UI.IssuePagingNum)
-
-	var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID}
+func (opts *SearchOptions) toConds() builder.Cond {
+	cond := builder.NewCond()
+	if opts.RepoID > 0 {
+		cond = cond.And(builder.Eq{"repo_id": opts.RepoID})
+	}
 	switch opts.IsClosed {
 	case util.OptionalBoolTrue:
 		cond = cond.And(builder.Eq{"is_closed": true})
@@ -142,6 +188,22 @@ func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, er
 	if opts.Type > 0 {
 		cond = cond.And(builder.Eq{"type": opts.Type})
 	}
+	if opts.OwnerID > 0 {
+		cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
+	}
+	return cond
+}
+
+// CountProjects counts projects
+func CountProjects(ctx context.Context, opts SearchOptions) (int64, error) {
+	return db.GetEngine(ctx).Where(opts.toConds()).Count(new(Project))
+}
+
+// FindProjects returns a list of all projects that have been created in the repository
+func FindProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) {
+	e := db.GetEngine(ctx)
+	projects := make([]*Project, 0, setting.UI.IssuePagingNum)
+	cond := opts.toConds()
 
 	count, err := e.Where(cond).Count(new(Project))
 	if err != nil {
@@ -188,8 +250,10 @@ func NewProject(p *Project) error {
 		return err
 	}
 
-	if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
-		return err
+	if p.RepoID > 0 {
+		if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil {
+			return err
+		}
 	}
 
 	if err := createBoardsForProjectsType(ctx, p); err != nil {
diff --git a/models/project/project_test.go b/models/project/project_test.go
index 4fde0fc7ce..c2d9005c43 100644
--- a/models/project/project_test.go
+++ b/models/project/project_test.go
@@ -22,7 +22,7 @@ func TestIsProjectTypeValid(t *testing.T) {
 	}{
 		{TypeIndividual, false},
 		{TypeRepository, true},
-		{TypeOrganization, false},
+		{TypeOrganization, true},
 		{UnknownType, false},
 	}
 
@@ -34,13 +34,13 @@ func TestIsProjectTypeValid(t *testing.T) {
 func TestGetProjects(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
-	projects, _, err := GetProjects(db.DefaultContext, SearchOptions{RepoID: 1})
+	projects, _, err := FindProjects(db.DefaultContext, SearchOptions{RepoID: 1})
 	assert.NoError(t, err)
 
 	// 1 value for this repo exists in the fixtures
 	assert.Len(t, projects, 1)
 
-	projects, _, err = GetProjects(db.DefaultContext, SearchOptions{RepoID: 3})
+	projects, _, err = FindProjects(db.DefaultContext, SearchOptions{RepoID: 3})
 	assert.NoError(t, err)
 
 	// 1 value for this repo exists in the fixtures
diff --git a/modules/context/org.go b/modules/context/org.go
index 39df29a860..ff3a5ae7ec 100644
--- a/modules/context/org.go
+++ b/modules/context/org.go
@@ -9,7 +9,9 @@ import (
 
 	"code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/models/perm"
+	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
+	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
 )
@@ -28,6 +30,32 @@ type Organization struct {
 	Teams []*organization.Team
 }
 
+func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool {
+	if ctx.Doer == nil {
+		return false
+	}
+	return org.UnitPermission(ctx, ctx.Doer.ID, unitType) >= perm.AccessModeWrite
+}
+
+func (org *Organization) UnitPermission(ctx *Context, doerID int64, unitType unit.Type) perm.AccessMode {
+	if doerID > 0 {
+		teams, err := organization.GetUserOrgTeams(ctx, org.Organization.ID, doerID)
+		if err != nil {
+			log.Error("GetUserOrgTeams: %v", err)
+			return perm.AccessModeNone
+		}
+		if len(teams) > 0 {
+			return teams.UnitMaxAccess(unitType)
+		}
+	}
+
+	if org.Organization.Visibility == structs.VisibleTypePublic {
+		return perm.AccessModeRead
+	}
+
+	return perm.AccessModeNone
+}
+
 // HandleOrgAssignment handles organization assignment
 func HandleOrgAssignment(ctx *Context, args ...bool) {
 	var (
diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go
new file mode 100644
index 0000000000..41323a3601
--- /dev/null
+++ b/routers/web/org/main_test.go
@@ -0,0 +1,17 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+	unittest.MainTest(m, &unittest.TestOptions{
+		GiteaRootPath: filepath.Join("..", "..", ".."),
+	})
+}
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
new file mode 100644
index 0000000000..1ce44d4866
--- /dev/null
+++ b/routers/web/org/projects.go
@@ -0,0 +1,670 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	issues_model "code.gitea.io/gitea/models/issues"
+	project_model "code.gitea.io/gitea/models/project"
+	"code.gitea.io/gitea/models/unit"
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/json"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
+	"code.gitea.io/gitea/modules/web"
+	shared_user "code.gitea.io/gitea/routers/web/shared/user"
+	"code.gitea.io/gitea/services/forms"
+)
+
+const (
+	tplProjects           base.TplName = "org/projects/list"
+	tplProjectsNew        base.TplName = "org/projects/new"
+	tplProjectsView       base.TplName = "org/projects/view"
+	tplGenericProjectsNew base.TplName = "user/project"
+)
+
+// MustEnableProjects check if projects are enabled in settings
+func MustEnableProjects(ctx *context.Context) {
+	if unit.TypeProjects.UnitGlobalDisabled() {
+		ctx.NotFound("EnableKanbanBoard", nil)
+		return
+	}
+}
+
+// Projects renders the home page of projects
+func Projects(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.project_board")
+
+	sortType := ctx.FormTrim("sort")
+
+	isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed"
+	page := ctx.FormInt("page")
+	if page <= 1 {
+		page = 1
+	}
+
+	projects, total, err := project_model.FindProjects(ctx, project_model.SearchOptions{
+		OwnerID:  ctx.ContextUser.ID,
+		Page:     page,
+		IsClosed: util.OptionalBoolOf(isShowClosed),
+		SortType: sortType,
+		Type:     project_model.TypeOrganization,
+	})
+	if err != nil {
+		ctx.ServerError("FindProjects", err)
+		return
+	}
+
+	opTotal, err := project_model.CountProjects(ctx, project_model.SearchOptions{
+		OwnerID:  ctx.ContextUser.ID,
+		IsClosed: util.OptionalBoolOf(!isShowClosed),
+		Type:     project_model.TypeOrganization,
+	})
+	if err != nil {
+		ctx.ServerError("CountProjects", err)
+		return
+	}
+
+	if isShowClosed {
+		ctx.Data["OpenCount"] = opTotal
+		ctx.Data["ClosedCount"] = total
+	} else {
+		ctx.Data["OpenCount"] = total
+		ctx.Data["ClosedCount"] = opTotal
+	}
+
+	ctx.Data["Projects"] = projects
+	shared_user.RenderUserHeader(ctx)
+
+	if isShowClosed {
+		ctx.Data["State"] = "closed"
+	} else {
+		ctx.Data["State"] = "open"
+	}
+
+	for _, project := range projects {
+		project.RenderedContent = project.Description
+	}
+
+	numPages := 0
+	if total > 0 {
+		numPages = (int(total) - 1/setting.UI.IssuePagingNum)
+	}
+
+	pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages)
+	pager.AddParam(ctx, "state", "State")
+	ctx.Data["Page"] = pager
+
+	ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+	ctx.Data["IsShowClosed"] = isShowClosed
+	ctx.Data["PageIsViewProjects"] = true
+	ctx.Data["SortType"] = sortType
+
+	ctx.HTML(http.StatusOK, tplProjects)
+}
+
+func canWriteUnit(ctx *context.Context) bool {
+	if ctx.ContextUser.IsOrganization() {
+		return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects)
+	}
+	return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID
+}
+
+// NewProject render creating a project page
+func NewProject(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
+	ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
+	ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+	ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink()
+	shared_user.RenderUserHeader(ctx)
+	ctx.HTML(http.StatusOK, tplProjectsNew)
+}
+
+// NewProjectPost creates a new project
+func NewProjectPost(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.CreateProjectForm)
+	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
+	shared_user.RenderUserHeader(ctx)
+
+	if ctx.HasError() {
+		ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+		ctx.Data["PageIsViewProjects"] = true
+		ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
+		ctx.HTML(http.StatusOK, tplProjectsNew)
+		return
+	}
+
+	if err := project_model.NewProject(&project_model.Project{
+		OwnerID:     ctx.ContextUser.ID,
+		Title:       form.Title,
+		Description: form.Content,
+		CreatorID:   ctx.Doer.ID,
+		BoardType:   form.BoardType,
+		Type:        project_model.TypeOrganization,
+	}); err != nil {
+		ctx.ServerError("NewProject", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
+	ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
+}
+
+// ChangeProjectStatus updates the status of a project between "open" and "close"
+func ChangeProjectStatus(ctx *context.Context) {
+	toClose := false
+	switch ctx.Params(":action") {
+	case "open":
+		toClose = false
+	case "close":
+		toClose = true
+	default:
+		ctx.Redirect(ctx.Repo.RepoLink + "/projects")
+	}
+	id := ctx.ParamsInt64(":id")
+
+	if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", err)
+		} else {
+			ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err)
+		}
+		return
+	}
+	ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action")))
+}
+
+// DeleteProject delete a project
+func DeleteProject(ctx *context.Context) {
+	p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+	if p.RepoID != ctx.Repo.Repository.ID {
+		ctx.NotFound("", nil)
+		return
+	}
+
+	if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
+		ctx.Flash.Error("DeleteProjectByID: " + err.Error())
+	} else {
+		ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"redirect": ctx.Repo.RepoLink + "/projects",
+	})
+}
+
+// EditProject allows a project to be edited
+func EditProject(ctx *context.Context) {
+	ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
+	ctx.Data["PageIsEditProjects"] = true
+	ctx.Data["PageIsViewProjects"] = true
+	ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+	shared_user.RenderUserHeader(ctx)
+
+	p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+	if p.RepoID != ctx.Repo.Repository.ID {
+		ctx.NotFound("", nil)
+		return
+	}
+
+	ctx.Data["title"] = p.Title
+	ctx.Data["content"] = p.Description
+
+	ctx.HTML(http.StatusOK, tplProjectsNew)
+}
+
+// EditProjectPost response for editing a project
+func EditProjectPost(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.CreateProjectForm)
+	ctx.Data["Title"] = ctx.Tr("repo.projects.edit")
+	ctx.Data["PageIsEditProjects"] = true
+	ctx.Data["PageIsViewProjects"] = true
+	ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+	shared_user.RenderUserHeader(ctx)
+
+	if ctx.HasError() {
+		ctx.HTML(http.StatusOK, tplProjectsNew)
+		return
+	}
+
+	p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+	if p.RepoID != ctx.Repo.Repository.ID {
+		ctx.NotFound("", nil)
+		return
+	}
+
+	p.Title = form.Title
+	p.Description = form.Content
+	if err = project_model.UpdateProject(ctx, p); err != nil {
+		ctx.ServerError("UpdateProjects", err)
+		return
+	}
+
+	ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
+	ctx.Redirect(ctx.Repo.RepoLink + "/projects")
+}
+
+// ViewProject renders the project board for a project
+func ViewProject(ctx *context.Context) {
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+	if project.OwnerID != ctx.ContextUser.ID {
+		ctx.NotFound("", nil)
+		return
+	}
+
+	boards, err := project_model.GetBoards(ctx, project.ID)
+	if err != nil {
+		ctx.ServerError("GetProjectBoards", err)
+		return
+	}
+
+	if boards[0].ID == 0 {
+		boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
+	}
+
+	issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards)
+	if err != nil {
+		ctx.ServerError("LoadIssuesOfBoards", err)
+		return
+	}
+
+	linkedPrsMap := make(map[int64][]*issues_model.Issue)
+	for _, issuesList := range issuesMap {
+		for _, issue := range issuesList {
+			var referencedIds []int64
+			for _, comment := range issue.Comments {
+				if comment.RefIssueID != 0 && comment.RefIsPull {
+					referencedIds = append(referencedIds, comment.RefIssueID)
+				}
+			}
+
+			if len(referencedIds) > 0 {
+				if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
+					IssueIDs: referencedIds,
+					IsPull:   util.OptionalBoolTrue,
+				}); err == nil {
+					linkedPrsMap[issue.ID] = linkedPrs
+				}
+			}
+		}
+	}
+
+	project.RenderedContent = project.Description
+	ctx.Data["LinkedPRs"] = linkedPrsMap
+	ctx.Data["PageIsViewProjects"] = true
+	ctx.Data["CanWriteProjects"] = canWriteUnit(ctx)
+	ctx.Data["Project"] = project
+	ctx.Data["IssuesMap"] = issuesMap
+	ctx.Data["Boards"] = boards
+	shared_user.RenderUserHeader(ctx)
+
+	ctx.HTML(http.StatusOK, tplProjectsView)
+}
+
+func getActionIssues(ctx *context.Context) []*issues_model.Issue {
+	commaSeparatedIssueIDs := ctx.FormString("issue_ids")
+	if len(commaSeparatedIssueIDs) == 0 {
+		return nil
+	}
+	issueIDs := make([]int64, 0, 10)
+	for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") {
+		issueID, err := strconv.ParseInt(stringIssueID, 10, 64)
+		if err != nil {
+			ctx.ServerError("ParseInt", err)
+			return nil
+		}
+		issueIDs = append(issueIDs, issueID)
+	}
+	issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
+	if err != nil {
+		ctx.ServerError("GetIssuesByIDs", err)
+		return nil
+	}
+	// Check access rights for all issues
+	issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues)
+	prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests)
+	for _, issue := range issues {
+		if issue.RepoID != ctx.Repo.Repository.ID {
+			ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect"))
+			return nil
+		}
+		if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled {
+			ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil)
+			return nil
+		}
+		if err = issue.LoadAttributes(ctx); err != nil {
+			ctx.ServerError("LoadAttributes", err)
+			return nil
+		}
+	}
+	return issues
+}
+
+// UpdateIssueProject change an issue's project
+func UpdateIssueProject(ctx *context.Context) {
+	issues := getActionIssues(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	projectID := ctx.FormInt64("id")
+	for _, issue := range issues {
+		oldProjectID := issue.ProjectID()
+		if oldProjectID == projectID {
+			continue
+		}
+
+		if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil {
+			ctx.ServerError("ChangeProjectAssign", err)
+			return
+		}
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
+
+// DeleteProjectBoard allows for the deletion of a project board
+func DeleteProjectBoard(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+
+	pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+	if err != nil {
+		ctx.ServerError("GetProjectBoard", err)
+		return
+	}
+	if pb.ProjectID != ctx.ParamsInt64(":id") {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID),
+		})
+		return
+	}
+
+	if project.OwnerID != ctx.ContextUser.ID {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID),
+		})
+		return
+	}
+
+	if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil {
+		ctx.ServerError("DeleteProjectBoardByID", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
+
+// AddBoardToProjectPost allows a new board to be added to a project.
+func AddBoardToProjectPost(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.EditProjectBoardForm)
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+
+	if err := project_model.NewBoard(&project_model.Board{
+		ProjectID: project.ID,
+		Title:     form.Title,
+		Color:     form.Color,
+		CreatorID: ctx.Doer.ID,
+	}); err != nil {
+		ctx.ServerError("NewProjectBoard", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
+
+// CheckProjectBoardChangePermissions check permission
+func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return nil, nil
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return nil, nil
+	}
+
+	board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+	if err != nil {
+		ctx.ServerError("GetProjectBoard", err)
+		return nil, nil
+	}
+	if board.ProjectID != ctx.ParamsInt64(":id") {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID),
+		})
+		return nil, nil
+	}
+
+	if project.OwnerID != ctx.ContextUser.ID {
+		ctx.JSON(http.StatusUnprocessableEntity, map[string]string{
+			"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID),
+		})
+		return nil, nil
+	}
+	return project, board
+}
+
+// EditProjectBoard allows a project board's to be updated
+func EditProjectBoard(ctx *context.Context) {
+	form := web.GetForm(ctx).(*forms.EditProjectBoardForm)
+	_, board := CheckProjectBoardChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	if form.Title != "" {
+		board.Title = form.Title
+	}
+
+	board.Color = form.Color
+
+	if form.Sorting != 0 {
+		board.Sorting = form.Sorting
+	}
+
+	if err := project_model.UpdateBoard(ctx, board); err != nil {
+		ctx.ServerError("UpdateProjectBoard", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
+
+// SetDefaultProjectBoard set default board for uncategorized issues/pulls
+func SetDefaultProjectBoard(ctx *context.Context) {
+	project, board := CheckProjectBoardChangePermissions(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil {
+		ctx.ServerError("SetDefaultBoard", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
+
+// MoveIssues moves or keeps issues in a column and sorts them inside that column
+func MoveIssues(ctx *context.Context) {
+	if ctx.Doer == nil {
+		ctx.JSON(http.StatusForbidden, map[string]string{
+			"message": "Only signed in users are allowed to perform this action.",
+		})
+		return
+	}
+
+	project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id"))
+	if err != nil {
+		if project_model.IsErrProjectNotExist(err) {
+			ctx.NotFound("ProjectNotExist", nil)
+		} else {
+			ctx.ServerError("GetProjectByID", err)
+		}
+		return
+	}
+	if project.OwnerID != ctx.ContextUser.ID {
+		ctx.NotFound("InvalidRepoID", nil)
+		return
+	}
+
+	var board *project_model.Board
+
+	if ctx.ParamsInt64(":boardID") == 0 {
+		board = &project_model.Board{
+			ID:        0,
+			ProjectID: project.ID,
+			Title:     ctx.Tr("repo.projects.type.uncategorized"),
+		}
+	} else {
+		board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID"))
+		if err != nil {
+			if project_model.IsErrProjectBoardNotExist(err) {
+				ctx.NotFound("ProjectBoardNotExist", nil)
+			} else {
+				ctx.ServerError("GetProjectBoard", err)
+			}
+			return
+		}
+		if board.ProjectID != project.ID {
+			ctx.NotFound("BoardNotInProject", nil)
+			return
+		}
+	}
+
+	type movedIssuesForm struct {
+		Issues []struct {
+			IssueID int64 `json:"issueID"`
+			Sorting int64 `json:"sorting"`
+		} `json:"issues"`
+	}
+
+	form := &movedIssuesForm{}
+	if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
+		ctx.ServerError("DecodeMovedIssuesForm", err)
+	}
+
+	issueIDs := make([]int64, 0, len(form.Issues))
+	sortedIssueIDs := make(map[int64]int64)
+	for _, issue := range form.Issues {
+		issueIDs = append(issueIDs, issue.IssueID)
+		sortedIssueIDs[issue.Sorting] = issue.IssueID
+	}
+	movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
+	if err != nil {
+		if issues_model.IsErrIssueNotExist(err) {
+			ctx.NotFound("IssueNotExisting", nil)
+		} else {
+			ctx.ServerError("GetIssueByID", err)
+		}
+		return
+	}
+
+	if len(movedIssues) != len(form.Issues) {
+		ctx.ServerError("some issues do not exist", errors.New("some issues do not exist"))
+		return
+	}
+
+	if _, err = movedIssues.LoadRepositories(ctx); err != nil {
+		ctx.ServerError("LoadRepositories", err)
+		return
+	}
+
+	for _, issue := range movedIssues {
+		if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID {
+			ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID"))
+			return
+		}
+	}
+
+	if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
+		ctx.ServerError("MoveIssuesOnProjectBoard", err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": true,
+	})
+}
diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go
new file mode 100644
index 0000000000..3450fa8e72
--- /dev/null
+++ b/routers/web/org/projects_test.go
@@ -0,0 +1,28 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package org_test
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+	"code.gitea.io/gitea/modules/test"
+	"code.gitea.io/gitea/routers/web/org"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCheckProjectBoardChangePermissions(t *testing.T) {
+	unittest.PrepareTestEnv(t)
+	ctx := test.MockContext(t, "user2/-/projects/4/4")
+	test.LoadUser(t, ctx, 2)
+	ctx.ContextUser = ctx.Doer // user2
+	ctx.SetParams(":id", "4")
+	ctx.SetParams(":boardID", "4")
+
+	project, board := org.CheckProjectBoardChangePermissions(ctx)
+	assert.NotNil(t, project)
+	assert.NotNil(t, board)
+	assert.False(t, ctx.Written())
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 59ab717a1d..44ac81f65d 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -363,7 +363,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 	}
 
 	if ctx.Repo.CanWriteIssuesOrPulls(ctx.Params(":type") == "pulls") {
-		projects, _, err := project_model.GetProjects(ctx, project_model.SearchOptions{
+		projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
 			RepoID:   repo.ID,
 			Type:     project_model.TypeRepository,
 			IsClosed: util.OptionalBoolOf(isShowClosed),
@@ -474,8 +474,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R
 
 func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
 	var err error
-
-	ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+	projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     -1,
 		IsClosed: util.OptionalBoolFalse,
@@ -485,8 +484,20 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
 		ctx.ServerError("GetProjects", err)
 		return
 	}
+	projects2, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{
+		OwnerID:  repo.OwnerID,
+		Page:     -1,
+		IsClosed: util.OptionalBoolFalse,
+		Type:     project_model.TypeOrganization,
+	})
+	if err != nil {
+		ctx.ServerError("GetProjects", err)
+		return
+	}
 
-	ctx.Data["ClosedProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+	ctx.Data["OpenProjects"] = append(projects, projects2...)
+
+	projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     -1,
 		IsClosed: util.OptionalBoolTrue,
@@ -496,6 +507,18 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
 		ctx.ServerError("GetProjects", err)
 		return
 	}
+	projects2, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
+		OwnerID:  repo.OwnerID,
+		Page:     -1,
+		IsClosed: util.OptionalBoolTrue,
+		Type:     project_model.TypeOrganization,
+	})
+	if err != nil {
+		ctx.ServerError("GetProjects", err)
+		return
+	}
+
+	ctx.Data["ClosedProjects"] = append(projects, projects2...)
 }
 
 // repoReviewerSelection items to bee shown
@@ -988,7 +1011,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
 			ctx.ServerError("GetProjectByID", err)
 			return nil, nil, 0, 0
 		}
-		if p.RepoID != ctx.Repo.Repository.ID {
+		if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID {
 			ctx.NotFound("", nil)
 			return nil, nil, 0, 0
 		}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 75cd290b8f..3becf799c5 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -70,7 +70,7 @@ func Projects(ctx *context.Context) {
 		total = repo.NumClosedProjects
 	}
 
-	projects, count, err := project_model.GetProjects(ctx, project_model.SearchOptions{
+	projects, count, err := project_model.FindProjects(ctx, project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     page,
 		IsClosed: util.OptionalBoolOf(isShowClosed),
@@ -112,7 +112,7 @@ func Projects(ctx *context.Context) {
 	pager.AddParam(ctx, "state", "State")
 	ctx.Data["Page"] = pager
 
-	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
+	ctx.Data["CanWriteProjects"] = true
 	ctx.Data["IsShowClosed"] = isShowClosed
 	ctx.Data["IsProjectsPage"] = true
 	ctx.Data["SortType"] = sortType
@@ -653,47 +653,3 @@ func MoveIssues(ctx *context.Context) {
 		"ok": true,
 	})
 }
-
-// CreateProject renders the generic project creation page
-func CreateProject(ctx *context.Context) {
-	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
-	ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
-	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
-
-	ctx.HTML(http.StatusOK, tplGenericProjectsNew)
-}
-
-// CreateProjectPost creates an individual and/or organization project
-func CreateProjectPost(ctx *context.Context, form forms.UserCreateProjectForm) {
-	user := checkContextUser(ctx, form.UID)
-	if ctx.Written() {
-		return
-	}
-
-	ctx.Data["ContextUser"] = user
-
-	if ctx.HasError() {
-		ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
-		ctx.HTML(http.StatusOK, tplGenericProjectsNew)
-		return
-	}
-
-	projectType := project_model.TypeIndividual
-	if user.IsOrganization() {
-		projectType = project_model.TypeOrganization
-	}
-
-	if err := project_model.NewProject(&project_model.Project{
-		Title:       form.Title,
-		Description: form.Content,
-		CreatorID:   user.ID,
-		BoardType:   form.BoardType,
-		Type:        projectType,
-	}); err != nil {
-		ctx.ServerError("NewProject", err)
-		return
-	}
-
-	ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
-	ctx.Redirect(setting.AppSubURL + "/")
-}
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
new file mode 100644
index 0000000000..94e59e2a49
--- /dev/null
+++ b/routers/web/shared/user/header.go
@@ -0,0 +1,14 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+func RenderUserHeader(ctx *context.Context) {
+	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
+	ctx.Data["ContextUser"] = ctx.ContextUser
+}
diff --git a/routers/web/user/package.go b/routers/web/user/package.go
index c0aba7583f..ed4f0dd797 100644
--- a/routers/web/user/package.go
+++ b/routers/web/user/package.go
@@ -19,6 +19,7 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/modules/web"
+	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/forms"
 	packages_service "code.gitea.io/gitea/services/packages"
 )
@@ -83,10 +84,10 @@ func ListPackages(ctx *context.Context) {
 		return
 	}
 
+	shared_user.RenderUserHeader(ctx)
+
 	ctx.Data["Title"] = ctx.Tr("packages.title")
 	ctx.Data["IsPackagesPage"] = true
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["ContextUser"] = ctx.ContextUser
 	ctx.Data["Query"] = query
 	ctx.Data["PackageType"] = packageType
 	ctx.Data["AvailableTypes"] = packages_model.TypeList
@@ -156,10 +157,10 @@ func RedirectToLastVersion(ctx *context.Context) {
 func ViewPackageVersion(ctx *context.Context) {
 	pd := ctx.Package.Descriptor
 
+	shared_user.RenderUserHeader(ctx)
+
 	ctx.Data["Title"] = pd.Package.Name
 	ctx.Data["IsPackagesPage"] = true
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["ContextUser"] = ctx.ContextUser
 	ctx.Data["PackageDescriptor"] = pd
 
 	var (
@@ -235,10 +236,10 @@ func ListPackageVersions(ctx *context.Context) {
 	query := ctx.FormTrim("q")
 	sort := ctx.FormTrim("sort")
 
+	shared_user.RenderUserHeader(ctx)
+
 	ctx.Data["Title"] = ctx.Tr("packages.title")
 	ctx.Data["IsPackagesPage"] = true
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["ContextUser"] = ctx.ContextUser
 	ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{
 		Package: p,
 		Owner:   ctx.Package.Owner,
@@ -311,10 +312,10 @@ func ListPackageVersions(ctx *context.Context) {
 func PackageSettings(ctx *context.Context) {
 	pd := ctx.Package.Descriptor
 
+	shared_user.RenderUserHeader(ctx)
+
 	ctx.Data["Title"] = pd.Package.Name
 	ctx.Data["IsPackagesPage"] = true
-	ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
-	ctx.Data["ContextUser"] = ctx.ContextUser
 	ctx.Data["PackageDescriptor"] = pd
 
 	repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 0002d56de0..0e342991d6 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -224,7 +224,7 @@ func Profile(ctx *context.Context) {
 
 		total = int(count)
 	case "projects":
-		ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{
+		ctx.Data["OpenProjects"], _, err = project_model.FindProjects(ctx, project_model.SearchOptions{
 			Page:     -1,
 			IsClosed: util.OptionalBoolFalse,
 			Type:     project_model.TypeIndividual,
diff --git a/routers/web/web.go b/routers/web/web.go
index f0fedd0715..d37d82820d 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -835,6 +835,46 @@ func RegisterRoutes(m *web.Route) {
 				})
 			}, ignSignIn, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
 		}
+
+		m.Group("/projects", func() {
+			m.Get("", org.Projects)
+			m.Get("/{id}", org.ViewProject)
+			m.Group("", func() { //nolint:dupl
+				m.Get("/new", org.NewProject)
+				m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost)
+				m.Group("/{id}", func() {
+					m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost)
+					m.Post("/delete", org.DeleteProject)
+
+					m.Get("/edit", org.EditProject)
+					m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost)
+					m.Post("/{action:open|close}", org.ChangeProjectStatus)
+
+					m.Group("/{boardID}", func() {
+						m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard)
+						m.Delete("", org.DeleteProjectBoard)
+						m.Post("/default", org.SetDefaultProjectBoard)
+
+						m.Post("/move", org.MoveIssues)
+					})
+				})
+			}, reqSignIn, func(ctx *context.Context) {
+				if ctx.ContextUser == nil {
+					ctx.NotFound("NewProject", nil)
+					return
+				}
+				if ctx.ContextUser.IsOrganization() {
+					if !ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) {
+						ctx.NotFound("NewProject", nil)
+						return
+					}
+				} else if ctx.ContextUser.ID != ctx.Doer.ID {
+					ctx.NotFound("NewProject", nil)
+					return
+				}
+			})
+		}, repo.MustEnableProjects)
+
 		m.Get("/code", user.CodeSearch)
 	}, context_service.UserAssignmentWeb())
 
@@ -1168,7 +1208,7 @@ func RegisterRoutes(m *web.Route) {
 		m.Group("/projects", func() {
 			m.Get("", repo.Projects)
 			m.Get("/{id}", repo.ViewProject)
-			m.Group("", func() {
+			m.Group("", func() { //nolint:dupl
 				m.Get("/new", repo.NewProject)
 				m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost)
 				m.Group("/{id}", func() {
diff --git a/services/context/user.go b/services/context/user.go
index 9dc84c3ac1..7642cba4e1 100644
--- a/services/context/user.go
+++ b/services/context/user.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"strings"
 
+	org_model "code.gitea.io/gitea/models/organization"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/context"
 )
@@ -56,6 +57,14 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{}))
 			} else {
 				errCb(http.StatusInternalServerError, "GetUserByName", err)
 			}
+		} else {
+			if ctx.ContextUser.IsOrganization() {
+				if ctx.Org == nil {
+					ctx.Org = &context.Organization{}
+				}
+				ctx.Org.Organization = (*org_model.Organization)(ctx.ContextUser)
+				ctx.Data["Org"] = ctx.Org.Organization
+			}
 		}
 	}
 }
diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl
index 87242b94d3..5f543424fc 100644
--- a/templates/org/menu.tmpl
+++ b/templates/org/menu.tmpl
@@ -3,6 +3,9 @@
 		<a class="{{if .PageIsViewRepositories}}active {{end}}item" href="{{$.Org.HomeLink}}">
 			{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
 		</a>
+		<a class="{{if .PageIsViewProjects}}active {{end}}item" href="{{$.Org.HomeLink}}/-/projects">
+			{{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+		</a>
 		{{if .IsPackageEnabled}}
 		<a class="item" href="{{$.Org.HomeLink}}/-/packages">
 			{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}
diff --git a/templates/org/projects/list.tmpl b/templates/org/projects/list.tmpl
new file mode 100644
index 0000000000..544ed38742
--- /dev/null
+++ b/templates/org/projects/list.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+	{{template "user/overview/header" .}}
+	{{template "projects/list" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/projects/new.tmpl b/templates/org/projects/new.tmpl
new file mode 100644
index 0000000000..b3d6c6001e
--- /dev/null
+++ b/templates/org/projects/new.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+	{{template "user/overview/header" .}}
+	{{template "projects/new" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/org/projects/view.tmpl b/templates/org/projects/view.tmpl
new file mode 100644
index 0000000000..03327e2530
--- /dev/null
+++ b/templates/org/projects/view.tmpl
@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="page-content repository packages">
+	{{template "user/overview/header" .}}
+	{{template "projects/view" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
new file mode 100644
index 0000000000..ae2eaec6ea
--- /dev/null
+++ b/templates/projects/list.tmpl
@@ -0,0 +1,98 @@
+<div class="page-content repository projects">
+	<div class="ui container">
+		{{if .CanWriteProjects}}
+			<div class="navbar">
+				<div class="ui right">
+					<a class="ui green button" href="{{$.Link}}/new">{{.locale.Tr "repo.projects.new"}}</a>
+				</div>
+			</div>
+			<div class="ui divider"></div>
+		{{end}}
+
+		{{template "base/alert" .}}
+		<div class="ui compact tiny menu">
+			<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=open">
+				{{svg "octicon-project" 16 "mr-3"}}
+				{{JsPrettyNumber .OpenCount}}&nbsp;{{.locale.Tr "repo.issues.open_title"}}
+			</a>
+			<a class="item{{if .IsShowClosed}} active{{end}}" href="{{$.Link}}?state=closed">
+				{{svg "octicon-check" 16 "mr-3"}}
+				{{JsPrettyNumber .ClosedCount}}&nbsp;{{.locale.Tr "repo.issues.closed_title"}}
+			</a>
+		</div>
+
+		<div class="ui right floated secondary filter menu">
+			<!-- Sort -->
+			<div class="ui dropdown type jump item">
+				<span class="text">
+					{{.locale.Tr "repo.issues.filter_sort"}}
+					{{svg "octicon-triangle-down" 14 "dropdown icon"}}
+				</span>
+				<div class="menu">
+					<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=oldest&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.oldest"}}</a>
+					<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=recentupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
+					<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="{{$.Link}}?q={{$.Keyword}}&sort=leastupdate&state={{$.State}}">{{.locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
+				</div>
+			</div>
+		</div>
+		<div class="milestone list">
+			{{range .Projects}}
+				<li class="item">
+					{{svg "octicon-project"}} <a href="{{$.Link}}/{{.ID}}">{{.Title}}</a>
+					<div class="meta">
+						{{$closedDate:= TimeSinceUnix .ClosedDateUnix $.locale}}
+						{{if .IsClosed}}
+							{{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.closed" $closedDate|Str2html}}
+						{{end}}
+						<span class="issue-stats">
+							{{svg "octicon-issue-opened" 16 "mr-3"}}
+							{{JsPrettyNumber .NumOpenIssues}}&nbsp;{{$.locale.Tr "repo.issues.open_title"}}
+							{{svg "octicon-check" 16 "mr-3"}}
+							{{JsPrettyNumber .NumClosedIssues}}&nbsp;{{$.locale.Tr "repo.issues.closed_title"}}
+						</span>
+					</div>
+					{{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}}
+					<div class="ui right operate">
+						<a href="{{$.Link}}/{{.ID}}/edit" data-id={{.ID}} data-title={{.Title}}>{{svg "octicon-pencil"}} {{$.locale.Tr "repo.issues.label_edit"}}</a>
+						{{if .IsClosed}}
+							<a class="link-action" href data-url="{{$.Link}}/{{.ID}}/open">{{svg "octicon-check"}} {{$.locale.Tr "repo.projects.open"}}</a>
+						{{else}}
+							<a class="link-action" href data-url="{{$.Link}}/{{.ID}}/close">{{svg "octicon-skip"}} {{$.locale.Tr "repo.projects.close"}}</a>
+						{{end}}
+						<a class="delete-button" href="#" data-url="{{$.RepoLink}}/projects/{{.ID}}/delete" data-id="{{.ID}}">{{svg "octicon-trash"}} {{$.locale.Tr "repo.issues.label_delete"}}</a>
+					</div>
+					{{end}}
+					{{if .Description}}
+					<div class="content">
+						{{.RenderedContent|Str2html}}
+					</div>
+					{{end}}
+				</li>
+			{{end}}
+
+			{{template "base/paginate" .}}
+		</div>
+	</div>
+</div>
+
+{{if or .CanWriteIssues .CanWritePulls}}
+<div class="ui small basic delete modal">
+	<div class="ui icon header">
+		{{svg "octicon-trash"}}
+		{{.locale.Tr "repo.projects.deletion"}}
+	</div>
+	<div class="content">
+		<p>{{.locale.Tr "repo.projects.deletion_desc"}}</p>
+	</div>
+	<div class="actions">
+		<div class="ui red basic inverted cancel button">
+			<i class="remove icon"></i>
+			{{.locale.Tr "modal.no"}}
+		</div>
+		<div class="ui green basic inverted ok button">
+			<i class="checkmark icon"></i>
+			{{.locale.Tr "modal.yes"}}
+		</div>
+	</div>
+</div>
+{{end}}
diff --git a/templates/projects/new.tmpl b/templates/projects/new.tmpl
new file mode 100644
index 0000000000..1069102792
--- /dev/null
+++ b/templates/projects/new.tmpl
@@ -0,0 +1,66 @@
+<div class="page-content repository projects edit-project new milestone">
+	<div class="ui container">
+		<div class="navbar">
+			{{if and .CanWriteProjects .PageIsEditProject}}
+			<div class="ui right floated secondary menu">
+				<a class="ui green button" href="{{$.HomeLink}}/-/projects/new">{{.locale.Tr "repo.milestones.new"}}</a>
+			</div>
+			{{end}}
+		</div>
+		<div class="ui divider"></div>
+		<h2 class="ui dividing header">
+			{{if .PageIsEditProjects}}
+			{{.locale.Tr "repo.projects.edit"}}
+			<div class="sub header">{{.locale.Tr "repo.projects.edit_subheader"}}</div>
+			{{else}}
+				{{.locale.Tr "repo.projects.new"}}
+				<div class="sub header">{{.locale.Tr "repo.projects.new_subheader"}}</div>
+				{{end}}
+		</h2>
+		{{template "base/alert" .}}
+		<form class="ui form grid" action="{{.Link}}" method="post">
+			{{.CsrfTokenHtml}}
+			<div class="eleven wide column">
+				<div class="field {{if .Err_Title}}error{{end}}">
+					<label>{{.locale.Tr "repo.projects.title"}}</label>
+					<input name="title" placeholder="{{.locale.Tr "repo.projects.title"}}" value="{{.title}}" autofocus required>
+				</div>
+				<div class="field">
+					<label>{{.locale.Tr "repo.projects.description"}}</label>
+					<textarea name="content" placeholder="{{.locale.Tr "repo.projects.description_placeholder"}}">{{.content}}</textarea>
+				</div>
+
+				{{if not .PageIsEditProjects}}
+					<label>{{.locale.Tr "repo.projects.template.desc"}}</label>
+					<div class="ui selection dropdown">
+						<input type="hidden" name="board_type" value="{{.type}}">
+						<div class="default text">{{.locale.Tr "repo.projects.template.desc_helper"}}</div>
+						<div class="menu">
+							{{range $element := .ProjectTypes}}
+								<div class="item" data-id="{{$element.BoardType}}" data-value="{{$element.BoardType}}">{{$.locale.Tr $element.Translation}}</div>
+							{{end}}
+						</div>
+					</div>
+				{{end}}
+			</div>
+			<div class="ui container">
+				<div class="ui divider"></div>
+				<div class="ui left">
+					{{if .PageIsEditProjects}}
+					<a class="ui primary basic button" href="{{.RepoLink}}/projects">
+						{{.locale.Tr "repo.milestones.cancel"}}
+					</a>
+					<button class="ui green button">
+						{{.locale.Tr "repo.projects.modify"}}
+					</button>
+					{{else}}
+						<button class="ui green button">
+							{{.locale.Tr "repo.projects.create"}}
+						</button>
+					{{end}}
+				</div>
+			</div>
+
+		</form>
+	</div>
+</div>
diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl
new file mode 100644
index 0000000000..ac72acb82b
--- /dev/null
+++ b/templates/projects/view.tmpl
@@ -0,0 +1,279 @@
+<div class="page-content repository projects view-project">
+	<div class="ui container">
+		<div class="ui two column stackable grid">
+			<div class="column">
+			</div>
+			<div class="column right aligned">
+				{{if .CanWriteProjects}}
+					<a class="ui green button show-modal item" data-modal="#new-board-item">{{.locale.Tr "new_project_board"}}</a>
+				{{end}}
+				<div class="ui small modal new-board-modal" id="new-board-item">
+					<div class="header">
+						{{$.locale.Tr "repo.projects.board.new"}}
+					</div>
+					<div class="content">
+						<form class="ui form">
+							<div class="required field">
+								<label for="new_board">{{$.locale.Tr "repo.projects.board.new_title"}}</label>
+								<input class="new-board" id="new_board" name="title" required>
+							</div>
+
+							<div class="field color-field">
+								<label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label>
+								<div class="color picker column">
+									<input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color_picker" name="color">
+									<div class="column precolors">
+										{{template "repo/issue/label_precolors"}}
+									</div>
+								</div>
+							</div>
+
+							<div class="text right actions">
+								<div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+								<button data-url="{{$.Link}}" class="ui green button" id="new_board_submit">{{$.locale.Tr "repo.projects.board.new_submit"}}</button>
+							</div>
+						</form>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div class="ui divider"></div>
+		<div class="ui two column stackable grid">
+			<div class="column">
+				<h2 class="project-title">{{$.Project.Title}}</h2>
+				<div class="content project-description">{{$.Project.RenderedContent|Str2html}}</div>
+			</div>
+			{{if or $.CanWriteIssues $.CanWritePulls}}
+				<div class="column right aligned">
+					<div class="ui compact right small menu">
+						<a class="item" href="{{$.Link}}/edit" data-id={{$.Project.ID}} data-title={{$.Project.Title}}>
+							{{svg "octicon-pencil"}}
+							<span class="mx-3">{{$.locale.Tr "repo.issues.label_edit"}}</span>
+						</a>
+						{{if .Project.IsClosed}}
+							<a class="item link-action" href data-url="{{$.Link}}/open">
+								{{svg "octicon-check"}}
+								<span class="mx-3">{{$.locale.Tr "repo.projects.open"}}</span>
+							</a>
+						{{else}}
+							<a class="item link-action" href data-url="{{$.Link}}/close">
+								{{svg "octicon-skip"}}
+								<span class="mx-3">{{$.locale.Tr "repo.projects.close"}}</span>
+							</a>
+						{{end}}
+						<a class="item delete-button" href="#" data-url="{{$.Link}}/delete" data-id="{{.Project.ID}}">
+							{{svg "octicon-trash"}}
+							<span class="mx-3">{{$.locale.Tr "repo.issues.label_delete"}}</span>
+						</a>
+					</div>
+				</div>
+			{{end}}
+		</div>
+		<div class="ui divider"></div>
+	</div>
+	<div class="ui container fluid padded" id="project-board">
+
+		<div class="board">
+			{{range $board := .Boards}}
+
+			<div class="ui segment board-column" style="background: {{.Color}} !important;" data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
+				<div class="board-column-header df ac sb">
+					<div class="ui large label board-label py-2">
+						<div class="ui small circular grey label board-card-cnt">
+							{{.NumIssues}}
+						</div>
+						{{.Title}}
+					</div>
+					{{if and $.CanWriteProjects (ne .ID 0)}}
+						<div class="ui dropdown jump item tooltip">
+							<div class="not-mobile px-3" tabindex="-1">
+								{{svg "octicon-kebab-horizontal"}}
+							</div>
+							<div class="menu user-menu" tabindex="-1">
+								<a class="item show-modal button" data-modal="#edit-project-board-modal-{{.ID}}">
+									{{svg "octicon-pencil"}}
+									{{$.locale.Tr "repo.projects.board.edit"}}
+								</a>
+								{{if not .Default}}
+									<a class="item show-modal button" data-modal="#set-default-project-board-modal-{{.ID}}">
+										{{svg "octicon-pin"}}
+										{{$.locale.Tr "repo.projects.board.set_default"}}
+									</a>
+								{{end}}
+								<a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}">
+									{{svg "octicon-trash"}}
+									{{$.locale.Tr "repo.projects.board.delete"}}
+								</a>
+
+								<div class="ui small modal edit-project-board" id="edit-project-board-modal-{{.ID}}">
+									<div class="header">
+										{{$.locale.Tr "repo.projects.board.edit"}}
+									</div>
+									<div class="content">
+										<form class="ui form">
+											<div class="required field">
+												<label for="new_board_title">{{$.locale.Tr "repo.projects.board.edit_title"}}</label>
+												<input class="project-board-title" id="new_board_title" name="title" value="{{.Title}}" required>
+											</div>
+
+											<div class="field color-field">
+												<label for="new_board_color">{{$.locale.Tr "repo.projects.board.color"}}</label>
+												<div class="color picker column">
+													<input class="color-picker" maxlength="7" placeholder="#c320f6" id="new_board_color" name="color" value="{{.Color}}">
+													<div class="column precolors">
+														{{template "repo/issue/label_precolors"}}
+													</div>
+												</div>
+											</div>
+
+											<div class="text right actions">
+												<div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+												<button data-url="{{$.Link}}/{{.ID}}" class="ui red button">{{$.locale.Tr "repo.projects.board.edit"}}</button>
+											</div>
+										</form>
+									</div>
+								</div>
+
+								<div class="ui basic modal" id="set-default-project-board-modal-{{.ID}}">
+									<div class="ui icon header">
+										{{$.locale.Tr "repo.projects.board.set_default"}}
+									</div>
+									<div class="content center">
+										<label>
+											{{$.locale.Tr "repo.projects.board.set_default_desc"}}
+										</label>
+									</div>
+									<div class="text right actions">
+										<div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+										<button class="ui red button set-default-project-board" data-url="{{$.Link}}/{{.ID}}/default">{{$.locale.Tr "repo.projects.board.set_default"}}</button>
+									</div>
+								</div>
+
+								<div class="ui basic modal" id="delete-board-modal-{{.ID}}">
+									<div class="ui icon header">
+										{{$.locale.Tr "repo.projects.board.delete"}}
+									</div>
+									<div class="content center">
+										<label>
+											{{$.locale.Tr "repo.projects.board.deletion_desc"}}
+										</label>
+									</div>
+									<div class="text right actions">
+										<div class="ui cancel button">{{$.locale.Tr "settings.cancel"}}</div>
+										<button class="ui red button delete-project-board" data-url="{{$.Link}}/{{.ID}}">{{$.locale.Tr "repo.projects.board.delete"}}</button>
+									</div>
+								</div>
+							</div>
+						</div>
+					{{end}}
+				</div>
+				<div class="ui divider"></div>
+
+				<div class="ui cards board" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
+
+					{{range (index $.IssuesMap .ID)}}
+
+					<!-- start issue card -->
+					<div class="card board-card" data-issue="{{.ID}}">
+						<div class="content p-0">
+							<div class="header">
+								<span class="dif ac vm {{if .IsClosed}}red{{else}}green{{end}}">
+									{{if .IsPull}}
+										{{if .PullRequest.HasMerged}}
+											{{svg "octicon-git-merge" 16 "text purple"}}
+										{{else}}
+											{{if .IsClosed}}
+												{{svg "octicon-git-pull-request" 16 "text red"}}
+											{{else}}
+												{{svg "octicon-git-pull-request" 16 "text green"}}
+											{{end}}
+										{{end}}
+									{{else}}
+										{{if .IsClosed}}
+											{{svg "octicon-issue-closed" 16 "text red"}}
+										{{else}}
+											{{svg "octicon-issue-opened" 16 "text green"}}
+										{{end}}
+									{{end}}
+								</span>
+								<a class="project-board-title vm" href="{{.Link}}">
+									{{.Title}}
+								</a>
+							</div>
+							<div class="meta my-2">
+								<span class="text light grey">
+									{{.Repo.FullName}}#{{.Index}}
+									{{$timeStr := TimeSinceUnix .GetLastEventTimestamp $.locale}}
+									{{if .OriginalAuthor}}
+										{{$.locale.Tr .GetLastEventLabelFake $timeStr (.OriginalAuthor|Escape) | Safe}}
+									{{else if gt .Poster.ID 0}}
+										{{$.locale.Tr .GetLastEventLabel $timeStr (.Poster.HomeLink|Escape) (.Poster.GetDisplayName | Escape) | Safe}}
+									{{else}}
+										{{$.locale.Tr .GetLastEventLabelFake $timeStr (.Poster.GetDisplayName | Escape) | Safe}}
+									{{end}}
+								</span>
+							</div>
+							{{- if .MilestoneID}}
+							<div class="meta my-2">
+								<a class="milestone" href="{{$.RepoLink}}/milestone/{{.MilestoneID}}">
+									{{svg "octicon-milestone" 16 "mr-2 vm"}}
+									<span class="vm">{{.Milestone.Name}}</span>
+								</a>
+							</div>
+							{{- end}}
+							{{- range index $.LinkedPRs .ID}}
+							<div class="meta my-2">
+								<a href="{{$.RepoLink}}/pulls/{{.Index}}">
+									<span class="m-0 {{if .PullRequest.HasMerged}}purple{{else if .IsClosed}}red{{else}}green{{end}}">{{svg "octicon-git-merge" 16 "mr-2 vm"}}</span>
+									<span class="vm">{{.Title}} <span class="text light grey">#{{.Index}}</span></span>
+								</a>
+							</div>
+							{{- end}}
+						</div>
+
+						{{if or .Labels .Assignees}}
+						<div class="extra content labels-list p-0 pt-2">
+							{{range .Labels}}
+								<a class="ui label" target="_blank" href="{{$.RepoLink}}/issues?labels={{.ID}}" style="color: {{.ForegroundColor}}; background-color: {{.Color}};" title="{{.Description | RenderEmojiPlain}}">{{.Name | RenderEmoji}}</a>
+							{{end}}
+							<div class="right floated">
+								{{range .Assignees}}
+									<a class="tooltip" target="_blank" href="{{.HTMLURL}}" data-content="{{$.locale.Tr "repo.projects.board.assigned_to"}} {{.Name}}">{{avatar . 28 "mini mr-3"}}</a>
+								{{end}}
+							</div>
+						</div>
+						{{end}}
+					</div>
+					<!-- stop issue card -->
+
+					{{end}}
+				</div>
+			</div>
+			{{end}}
+		</div>
+
+	</div>
+
+</div>
+
+{{if or .CanWriteIssues .CanWritePulls}}
+	<div class="ui small basic delete modal">
+		<div class="ui icon header">
+			{{svg "octicon-trash"}}
+			{{.locale.Tr "repo.projects.deletion"}}
+		</div>
+		<div class="content">
+			<p>{{.locale.Tr "repo.projects.deletion_desc"}}</p>
+		</div>
+		<div class="actions">
+			<div class="ui red basic inverted cancel button">
+				<i class="remove icon"></i>
+				{{.locale.Tr "modal.no"}}
+			</div>
+			<div class="ui green basic inverted ok button">
+				<i class="checkmark icon"></i>
+				{{.locale.Tr "modal.yes"}}
+			</div>
+		</div>
+	</div>
+{{end}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl
index 6cb00fdd1d..ca947e3612 100644
--- a/templates/repo/issue/view_content/sidebar.tmpl
+++ b/templates/repo/issue/view_content/sidebar.tmpl
@@ -219,8 +219,8 @@
 							{{.locale.Tr "repo.issues.new.open_projects"}}
 						</div>
 						{{range .OpenProjects}}
-							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">
-								{{svg "octicon-project" 18 "mr-3"}}
+							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}">
+								{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
 								{{.Title}}
 							</a>
 						{{end}}
@@ -231,8 +231,8 @@
 							{{.locale.Tr "repo.issues.new.closed_projects"}}
 						</div>
 						{{range .ClosedProjects}}
-							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{$.RepoLink}}/projects/{{.ID}}">
-								{{svg "octicon-project" 18 "mr-3"}}
+							<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link}}">
+								{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
 								{{.Title}}
 							</a>
 						{{end}}
@@ -243,8 +243,8 @@
 				<span class="no-select item {{if .Issue.ProjectID}}hide{{end}}">{{.locale.Tr "repo.issues.new.no_projects"}}</span>
 				<div class="selected">
 					{{if .Issue.ProjectID}}
-						<a class="item muted sidebar-item-link" href="{{.RepoLink}}/projects/{{.Issue.ProjectID}}">
-							{{svg "octicon-project" 18 "mr-3"}}
+						<a class="item muted sidebar-item-link" href="{{.Issue.Project.Link}}">
+							{{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}}
 							{{.Issue.Project.Title}}
 						</a>
 					{{end}}
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl
index 61b19c6032..8fb882718c 100644
--- a/templates/user/overview/header.tmpl
+++ b/templates/user/overview/header.tmpl
@@ -22,6 +22,9 @@
 			<a class="item" href="{{.ContextUser.HomeLink}}">
 				{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
 			</a>
+			<a href="{{.ContextUser.HomeLink}}/-/projects" class="{{if .PageIsViewProjects}}active {{end}}item">
+				{{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+			</a>
 			{{if (not .UnitPackagesGlobalDisabled)}}
 				<a href="{{.ContextUser.HomeLink}}/-/packages" class="{{if .IsPackagesPage}}active {{end}}item">
 					{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl
index 6c31723e0f..74211eb67b 100644
--- a/templates/user/profile.tmpl
+++ b/templates/user/profile.tmpl
@@ -106,6 +106,9 @@
 					<a class='{{if and (ne .TabName "activity") (ne .TabName "following") (ne .TabName "followers") (ne .TabName "stars") (ne .TabName "watching") (ne .TabName "projects") (ne .TabName "code")}}active {{end}}item' href="{{.Owner.HomeLink}}">
 						{{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}}
 					</a>
+					<a href="{{.Owner.HomeLink}}/-/projects" class="{{if eq .TabName "projects"}}active {{end}}item">
+						{{svg "octicon-project"}} {{.locale.Tr "user.projects"}}
+					</a>
 					{{if .IsPackageEnabled}}
 					<a class='{{if eq .TabName "packages"}}active {{end}}item' href="{{.Owner.HomeLink}}/-/packages">
 						{{svg "octicon-package"}} {{.locale.Tr "packages.title"}}