diff --git a/pkg/routes/api/v2/projects.go b/pkg/routes/api/v2/projects.go
new file mode 100644
index 000000000..c8e45018d
--- /dev/null
+++ b/pkg/routes/api/v2/projects.go
@@ -0,0 +1,182 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package apiv2
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/web/handler"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/danielgtaylor/huma/v2/conditional"
+)
+
+// projectListBody is the list-response envelope. models.Project.ReadAll
+// returns []*models.Project, so that's the element type.
+type projectListBody struct {
+ Body Paginated[*models.Project]
+}
+
+// RegisterProjectRoutes wires Project CRUD onto the Huma API.
+func RegisterProjectRoutes(api huma.API) {
+ tags := []string{"projects"}
+
+ Register(api, huma.Operation{
+ OperationID: "projects-list",
+ Summary: "List projects",
+ Description: "Returns the projects the authenticated user has access to (owned plus shared, with child projects of accessible parents), paginated. Archived projects are excluded unless is_archived=true. Pass expand=permissions to include each project's max_permission for the caller.",
+ Method: http.MethodGet,
+ Path: "/projects",
+ Tags: tags,
+ }, projectsList)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-read",
+ Summary: "Get a project",
+ Description: "Returns a single project the caller can read, including its views and the caller's favorite/subscription state. Resolves the Favorites pseudo-project and saved-filter-backed projects. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
+ Method: http.MethodGet,
+ Path: "/projects/{id}",
+ Tags: tags,
+ }, projectsRead)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-create",
+ Summary: "Create a project",
+ Description: "Creates a project; the authenticated user becomes its owner. When parent_project_id is set, the caller needs write access to that parent. Default views and a backlog bucket are created automatically.",
+ Method: http.MethodPost,
+ Path: "/projects",
+ Tags: tags,
+ }, projectsCreate)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-update",
+ Summary: "Update a project",
+ Description: "Replaces a project's fields. Requires write access (admin to reparent or delete). Use PATCH for a partial update.",
+ Method: http.MethodPut,
+ Path: "/projects/{id}",
+ Tags: tags,
+ }, projectsUpdate)
+
+ Register(api, huma.Operation{
+ OperationID: "projects-delete",
+ Summary: "Delete a project",
+ Description: "Deletes a project together with its tasks, views, buckets and child projects. Only project admins may delete it.",
+ Method: http.MethodDelete,
+ Path: "/projects/{id}",
+ Tags: tags,
+ }, projectsDelete)
+}
+
+func init() { AddRouteRegistrar(RegisterProjectRoutes) }
+
+func projectsList(ctx context.Context, in *struct {
+ ListParams
+ Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", each returned project includes the max permission the requesting user has on it (max_permission). Currently only \"permissions\" is supported."`
+ IsArchived bool `query:"is_archived" doc:"If true, also returns archived projects."`
+}) (*projectListBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ p := &models.Project{
+ Expand: models.ProjectExpandable(in.Expand),
+ IsArchived: in.IsArchived,
+ }
+ result, _, total, err := handler.DoReadAll(ctx, p, a, in.Q, in.Page, in.PerPage)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ items, ok := result.([]*models.Project)
+ if !ok {
+ return nil, fmt.Errorf("projects.ReadAll returned unexpected type %T (expected []*models.Project)", result)
+ }
+ return &projectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
+}
+
+func projectsRead(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+ Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", the project includes the max permission the requesting user has on it (max_permission)."`
+ conditional.Params
+}) (*singleReadBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ project := &models.Project{ID: in.ID, Expand: models.ProjectExpandable(in.Expand)}
+ maxPermission, err := handler.DoReadOne(ctx, project, a)
+ if err != nil {
+ return nil, translateDomainError(err)
+ }
+ // ReadOne doesn't act on Expand itself; the caller's max permission comes
+ // from DoReadOne's CanRead result. Surface it on the model only when asked,
+ // matching the list operation's expand=permissions behaviour.
+ if models.ProjectExpandable(in.Expand) == models.ProjectExpandableRights {
+ project.MaxPermission = models.Permission(maxPermission)
+ }
+ // PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
+ etag := fmt.Sprintf("%d-%d", project.ID, project.Updated.UnixNano())
+ if in.HasConditionalParams() {
+ if err := in.PreconditionFailed(etag, project.Updated); err != nil {
+ return nil, err
+ }
+ }
+ return &singleReadBody[models.Project]{ETag: `"` + etag + `"`, Body: project}, nil
+}
+
+func projectsCreate(ctx context.Context, in *struct {
+ Body models.Project
+}) (*singleBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Project]{Body: &in.Body}, nil
+}
+
+func projectsUpdate(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+ Body models.Project
+}) (*singleBody[models.Project], error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ in.Body.ID = in.ID // URL wins over body
+ if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &singleBody[models.Project]{Body: &in.Body}, nil
+}
+
+func projectsDelete(ctx context.Context, in *struct {
+ ID int64 `path:"id"`
+}) (*emptyBody, error) {
+ a, err := authFromCtx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := handler.DoDelete(ctx, &models.Project{ID: in.ID}, a); err != nil {
+ return nil, translateDomainError(err)
+ }
+ return &emptyBody{}, nil
+}
diff --git a/pkg/webtests/huma_project_test.go b/pkg/webtests/huma_project_test.go
new file mode 100644
index 000000000..da820e3f8
--- /dev/null
+++ b/pkg/webtests/huma_project_test.go
@@ -0,0 +1,194 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package webtests
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestHumaProject mirrors v1's TestProject shape so v2 contract parity is
+// readable side-by-side. Status-code differences from v1 are noted inline.
+func TestHumaProject(t *testing.T) {
+ testHandler := webHandlerTestV2{
+ user: &testuser1,
+ basePath: "/api/v2/projects",
+ idParam: "project",
+ t: t,
+ }
+
+ t.Run("ReadAll", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testReadAllWithUser(nil, nil)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Test1`)
+ assert.NotContains(t, rec.Body.String(), `Test2"`)
+ assert.Contains(t, rec.Body.String(), `Test3`) // Shared directly via users_project
+ assert.Contains(t, rec.Body.String(), `Test12`) // Shared via parent project
+ assert.NotContains(t, rec.Body.String(), `Test5`)
+ assert.NotContains(t, rec.Body.String(), `Test21`) // Archived through parent project
+ assert.NotContains(t, rec.Body.String(), `Test22`) // Archived directly
+ })
+ t.Run("Normal with archived projects", func(t *testing.T) {
+ rec, err := testHandler.testReadAllWithUser(url.Values{"is_archived": []string{"true"}}, nil)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `Test1`)
+ assert.Contains(t, rec.Body.String(), `Test21`) // Archived through project
+ assert.Contains(t, rec.Body.String(), `Test22`) // Archived directly
+ })
+ t.Run("Expand permissions", func(t *testing.T) {
+ rec, err := testHandler.testReadAllWithUser(url.Values{"expand": []string{"permissions"}}, nil)
+ require.NoError(t, err)
+ // User 1 owns Test1 → admin (2). With expand the field carries a real value.
+ assert.Contains(t, rec.Body.String(), `"max_permission":2`)
+ })
+ })
+
+ t.Run("ReadOne", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "1"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Test1"`)
+ assert.NotContains(t, rec.Body.String(), `"title":"Test2"`)
+ assert.Contains(t, rec.Body.String(), `"username":"user1"`)
+ assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
+ })
+ t.Run("Expand permissions", func(t *testing.T) {
+ // User 1 owns Test1 → admin (2); expand surfaces it as max_permission.
+ rec, err := testHandler.testReadOneWithUser(url.Values{"expand": []string{"permissions"}}, map[string]string{"project": "1"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"max_permission":2`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ // Projects return 404 here (CanRead → GetProjectSimpleByID → ErrProjectDoesNotExist),
+ // unlike labels which return 403 from the read branch.
+ _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "9999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Permissions check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ // Project 20 exists but is owned by user13: CanRead returns false → 403.
+ _, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "20"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared Via Team readonly", func(t *testing.T) {
+ rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "6"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Test6"`)
+ })
+ t.Run("Shared Via User admin", func(t *testing.T) {
+ rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"project": "11"})
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"Test11"`)
+ })
+ })
+ })
+
+ t.Run("Create", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testCreateWithUser(nil, nil, `{"title":"Lorem","description":"Ipsum"}`)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusCreated, rec.Code)
+ assert.Contains(t, rec.Body.String(), `"title":"Lorem"`)
+ assert.Contains(t, rec.Body.String(), `"description":"Ipsum"`)
+ })
+ t.Run("Empty title", func(t *testing.T) {
+ // v2 returns 422, not v1's 400; full body shape asserted in TestHuma_ErrorShapeIsRFC9457.
+ _, err := testHandler.testCreateWithUser(nil, nil, `{"title":""}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
+ })
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "1"}, `{"title":"TestLoremIpsum"}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`)
+ // The description should not be wiped but returned as it was.
+ assert.Contains(t, rec.Body.String(), `"description":"Lorem Ipsum`)
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "9999"}, `{"title":"TestLoremIpsum"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Empty title", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "1"}, `{"title":""}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
+ })
+ t.Run("Permissions check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ // Owned by user13.
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "20"}, `{"title":"TestLoremIpsum"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared Via Team readonly forbidden", func(t *testing.T) {
+ _, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "6"}, `{"title":"TestLoremIpsum"}`)
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared Via Team write allowed", func(t *testing.T) {
+ rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"project": "7"}, `{"title":"TestLoremIpsum"}`)
+ require.NoError(t, err)
+ assert.Contains(t, rec.Body.String(), `"title":"TestLoremIpsum"`)
+ })
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "1"})
+ require.NoError(t, err)
+ // v2 delete is 204 No Content; v1 returned 200 + a message body.
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ assert.Empty(t, rec.Body.String())
+ })
+ t.Run("Nonexisting", func(t *testing.T) {
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "999"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
+ })
+ t.Run("Permissions check", func(t *testing.T) {
+ t.Run("Forbidden", func(t *testing.T) {
+ // Owned by user13.
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "20"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared Via Team write forbidden", func(t *testing.T) {
+ // Write access is not enough to delete; needs admin.
+ _, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "7"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
+ })
+ t.Run("Shared Via Team admin allowed", func(t *testing.T) {
+ rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"project": "8"})
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, rec.Code)
+ })
+ })
+ })
+}