diff --git a/apps/api/internal/handler/project.go b/apps/api/internal/handler/project.go index 4c38f690..ee595333 100644 --- a/apps/api/internal/handler/project.go +++ b/apps/api/internal/handler/project.go @@ -95,6 +95,7 @@ func (h *ProjectHandler) Create(c *gin.Context) { ProjectLeadID *string `json:"project_lead_id"` DefaultAssigneeID *string `json:"default_assignee_id"` GuestViewAllFeatures *bool `json:"guest_view_all_features"` + Network *int16 `json:"network"` ModuleView *bool `json:"module_view"` CycleView *bool `json:"cycle_view"` IssueViewsView *bool `json:"issue_views_view"` @@ -106,6 +107,13 @@ func (h *ProjectHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) return } + // Validate network before creating anything: Create persists the project first + // and only then applies network via Update, so an invalid value would otherwise + // leave behind a default-network project and a client 400 that invites a retry. + if body.Network != nil && *body.Network != model.NetworkPublic && *body.Network != model.NetworkSecret { + c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrInvalidNetwork.Error()}) + return + } p, err := h.Project.Create(c.Request.Context(), slug, body.Name, body.Identifier, user.ID) if err != nil { if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { @@ -133,6 +141,7 @@ func (h *ProjectHandler) Create(c *gin.Context) { body.ProjectLeadID != nil || body.DefaultAssigneeID != nil || body.GuestViewAllFeatures != nil || + body.Network != nil || body.ModuleView != nil || body.CycleView != nil || body.IssueViewsView != nil || @@ -207,6 +216,7 @@ func (h *ProjectHandler) Create(c *gin.Context) { defaultAssigneeSet, defaultAssigneeIDPtr, body.GuestViewAllFeatures, + body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, @@ -215,6 +225,10 @@ func (h *ProjectHandler) Create(c *gin.Context) { body.IsTimeTrackingEnabled, ) if err != nil { + if err == service.ErrInvalidNetwork { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } // If the follow-up update fails, still return the base project creation result. c.JSON(http.StatusCreated, p) return @@ -250,6 +264,7 @@ func (h *ProjectHandler) Update(c *gin.Context) { ProjectLeadID *string `json:"project_lead_id"` DefaultAssigneeID *string `json:"default_assignee_id"` GuestViewAllFeatures *bool `json:"guest_view_all_features"` + Network *int16 `json:"network"` ModuleView *bool `json:"module_view"` CycleView *bool `json:"cycle_view"` IssueViewsView *bool `json:"issue_views_view"` @@ -313,13 +328,13 @@ func (h *ProjectHandler) Update(c *gin.Context) { defaultAssigneeIDPtr = &id } } - p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled) + p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled) if err != nil { if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - if err == service.ErrProjectIdentifierTooLong { + if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/apps/api/internal/handler/project_network_test.go b/apps/api/internal/handler/project_network_test.go new file mode 100644 index 00000000..997d5805 --- /dev/null +++ b/apps/api/internal/handler/project_network_test.go @@ -0,0 +1,99 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func projectListHas(t *testing.T, rr *httptest.ResponseRecorder, id uuid.UUID) bool { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + var rows []struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rows)) + for _, r := range rows { + if r.ID == id.String() { + return true + } + } + return false +} + +// A secret (network=0) project is hidden from workspace members who aren't +// members of it, while a public one stays visible; members and the invalid +// value are handled too. Covers #197. +func TestProject_NetworkVisibility(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // A plain workspace member who is not a member of the project. + outsider := testutil.CreateUser(t, ts.DB) + testutil.AddWorkspaceMember(t, ts.DB, w.Workspace.ID, outsider.ID, model.RoleMember) + outsiderSession := testutil.LoginAs(t, ts.DB, outsider) + + listURL := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + projectURL := listURL + w.Project.ID.String() + "/" + + // Public by default: the outsider sees and can open it. + require.True(t, projectListHas(t, ts.GET(listURL, outsiderSession), w.Project.ID)) + require.Equal(t, http.StatusOK, ts.GET(projectURL, outsiderSession).Code) + + // Make it secret. + require.Equal(t, http.StatusOK, + ts.PATCH(projectURL, map[string]any{"network": 0}, w.Session).Code) + + // The outsider can no longer see or open it. + require.False(t, projectListHas(t, ts.GET(listURL, outsiderSession), w.Project.ID), + "secret project must be hidden from non-members") + require.Equal(t, http.StatusNotFound, ts.GET(projectURL, outsiderSession).Code) + + // The owner (a project member) still sees and can open it. + require.True(t, projectListHas(t, ts.GET(listURL, w.Session), w.Project.ID)) + require.Equal(t, http.StatusOK, ts.GET(projectURL, w.Session).Code) + + // Restoring it to public makes it visible again. + require.Equal(t, http.StatusOK, + ts.PATCH(projectURL, map[string]any{"network": 2}, w.Session).Code) + require.True(t, projectListHas(t, ts.GET(listURL, outsiderSession), w.Project.ID)) + + // An out-of-range network value is rejected. + require.Equal(t, http.StatusBadRequest, + ts.PATCH(projectURL, map[string]any{"network": 5}, w.Session).Code) +} + +func projectListCount(t *testing.T, rr *httptest.ResponseRecorder) int { + t.Helper() + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + var rows []json.RawMessage + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &rows)) + return len(rows) +} + +// Creating a project with an out-of-range network is rejected up front and +// leaves no half-created project behind (Create persists before it can apply +// network via Update). Covers #197. +func TestProject_CreateInvalidNetworkNoSideEffect(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + listURL := "/api/workspaces/" + w.Workspace.Slug + "/projects/" + before := projectListCount(t, ts.GET(listURL, w.Session)) + + rr := ts.POST(listURL, map[string]any{ + "name": "Bad Network", + "identifier": "BADNET", + "network": 5, + }, w.Session) + require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) + + after := projectListCount(t, ts.GET(listURL, w.Session)) + require.Equal(t, before, after, "invalid create must not leave a project behind") +} diff --git a/apps/api/internal/model/project.go b/apps/api/internal/model/project.go index 07494bee..b7f32842 100644 --- a/apps/api/internal/model/project.go +++ b/apps/api/internal/model/project.go @@ -10,6 +10,14 @@ import ( "gorm.io/gorm" ) +// Project network (visibility) values. +const ( + // NetworkSecret projects are visible only to their members (and workspace admins). + NetworkSecret int16 = 0 + // NetworkPublic projects are visible to every member of the workspace. + NetworkPublic int16 = 2 +) + // JSONMap for JSONB columns. type JSONMap map[string]interface{} diff --git a/apps/api/internal/service/project.go b/apps/api/internal/service/project.go index e02d1ce0..fd162959 100644 --- a/apps/api/internal/service/project.go +++ b/apps/api/internal/service/project.go @@ -18,6 +18,7 @@ var ( ErrProjectNotFound = errors.New("project not found") ErrProjectForbidden = errors.New("no access to this project") ErrProjectIdentifierTooLong = errors.New("project identifier must be at most 7 characters") + ErrInvalidNetwork = errors.New("network must be public or secret") ) // ProjectService handles project business logic. @@ -32,6 +33,13 @@ func NewProjectService(ps *store.ProjectStore, pinv *store.ProjectInviteStore, w return &ProjectService{ps: ps, pinv: pinv, ws: ws, us: us} } +// isWorkspaceAdmin reports whether the user is an admin/owner of the workspace, +// who can see and manage every project regardless of its network visibility. +func (s *ProjectService) isWorkspaceAdmin(ctx context.Context, workspaceID, userID uuid.UUID) bool { + wm, err := s.ws.GetMember(ctx, workspaceID, userID) + return err == nil && wm != nil && wm.Role >= model.RoleAdmin +} + func (s *ProjectService) ListByWorkspace(ctx context.Context, workspaceSlug string, userID uuid.UUID) ([]model.Project, error) { wrk, err := s.ws.GetBySlug(context.Background(), workspaceSlug) if err != nil { @@ -41,7 +49,12 @@ func (s *ProjectService) ListByWorkspace(ctx context.Context, workspaceSlug stri if !ok { return nil, ErrProjectForbidden } - return s.ps.ListByWorkspaceID(ctx, wrk.ID) + // Workspace admins see everything; everyone else sees public projects plus + // the secret ones they belong to. + if s.isWorkspaceAdmin(ctx, wrk.ID, userID) { + return s.ps.ListByWorkspaceID(ctx, wrk.ID) + } + return s.ps.ListVisibleByWorkspaceID(ctx, wrk.ID, userID) } func (s *ProjectService) GetByID(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) (*model.Project, error) { @@ -57,7 +70,18 @@ func (s *ProjectService) GetByID(ctx context.Context, workspaceSlug string, proj if !inWorkspace { return nil, ErrProjectNotFound } - return s.ps.GetByID(ctx, projectID) + p, err := s.ps.GetByID(ctx, projectID) + if err != nil { + return nil, err + } + // A secret project is reachable only by its members (or a workspace admin). + if p.Network != model.NetworkPublic && !s.isWorkspaceAdmin(ctx, wrk.ID, userID) { + pm, _ := s.ps.GetProjectMember(ctx, projectID, userID) + if pm == nil { + return nil, ErrProjectNotFound + } + } + return p, nil } // projectCallerRole returns the caller's effective role for admin actions on @@ -110,7 +134,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi return p, nil } -func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) { +func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, network *int16, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) { p, err := s.GetByID(ctx, workspaceSlug, projectID, userID) if err != nil { return nil, err @@ -154,6 +178,12 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje if guestViewAllFeatures != nil { p.GuestViewAllFeatures = *guestViewAllFeatures } + if network != nil { + if *network != model.NetworkPublic && *network != model.NetworkSecret { + return nil, ErrInvalidNetwork + } + p.Network = *network + } if moduleView != nil { p.ModuleView = *moduleView } diff --git a/apps/api/internal/store/project.go b/apps/api/internal/store/project.go index 4917d605..0d9b4fa4 100644 --- a/apps/api/internal/store/project.go +++ b/apps/api/internal/store/project.go @@ -55,6 +55,21 @@ func (s *ProjectStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.U return list, err } +// ListVisibleByWorkspaceID returns the projects a user may see: every public +// project in the workspace, plus any project (public or secret) they belong to. +func (s *ProjectStore) ListVisibleByWorkspaceID(ctx context.Context, workspaceID, userID uuid.UUID) ([]model.Project, error) { + var list []model.Project + memberProjects := s.db.Model(&model.ProjectMember{}). + Select("project_id"). + Where("member_id = ? AND deleted_at IS NULL", userID) + err := s.db.WithContext(ctx). + Where("workspace_id = ? AND deleted_at IS NULL", workspaceID). + Where("network = ? OR id IN (?)", model.NetworkPublic, memberProjects). + Order("created_at ASC"). + Find(&list).Error + return list, err +} + func (s *ProjectStore) Update(ctx context.Context, p *model.Project) error { return s.db.WithContext(ctx).Save(p).Error } diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 78f4c401..66817133 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -67,6 +67,8 @@ export interface CreateProjectRequest { project_lead_id?: string; default_assignee_id?: string; guest_view_all_features?: boolean; + /** 2 = public (any workspace member), 0 = secret (members only). */ + network?: number; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean; @@ -96,6 +98,8 @@ export interface ProjectApiResponse { project_lead_id?: string | null; default_assignee_id?: string | null; guest_view_all_features?: boolean; + /** 2 = public (any workspace member), 0 = secret (members only). */ + network?: number; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean; diff --git a/apps/web/src/components/CreateProjectModal.tsx b/apps/web/src/components/CreateProjectModal.tsx index d0cce97a..c19f876f 100644 --- a/apps/web/src/components/CreateProjectModal.tsx +++ b/apps/web/src/components/CreateProjectModal.tsx @@ -115,7 +115,7 @@ export function CreateProjectModal({ cover_image: coverImage || undefined, emoji: emoji ?? undefined, icon_prop: iconProp ?? undefined, - guest_view_all_features: network === 'public' ? true : undefined, + network: network === 'public' ? 2 : 0, project_lead_id: projectLeadId ?? undefined, }; diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 9e03ba52..d508e70c 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -221,8 +221,8 @@ export function SettingsPage() { setProjectName(selectedProject.name); setProjectDescription(selectedProject.description ?? ''); if (selectedProject.timezone != null) setProjectTimezone(selectedProject.timezone); - // Derive Network dropdown value from guest_view_all_features so it reflects persisted visibility - setProjectNetwork(selectedProject.guest_view_all_features ? 'public' : 'private'); + // Reflect the project's persisted network visibility (2 = public, 0 = secret). + setProjectNetwork(selectedProject.network === 0 ? 'private' : 'public'); setProjectLeadId(selectedProject.project_lead_id ?? null); setDefaultAssigneeId(selectedProject.default_assignee_id ?? null); setGuestAccess(selectedProject.guest_view_all_features ?? false); @@ -242,6 +242,7 @@ export function SettingsPage() { selectedProject?.project_lead_id, selectedProject?.default_assignee_id, selectedProject?.guest_view_all_features, + selectedProject?.network, selectedProject?.cycle_view, selectedProject?.module_view, selectedProject?.issue_views_view, @@ -1713,22 +1714,22 @@ export function SettingsPage() { { - // Map network dropdown to the same guest_view_all_features flag used elsewhere - const nextGuestAccess = v === 'public'; - setProjectNetwork(v); - setGuestAccess(nextGuestAccess); if (!workspaceSlug || !selectedProjectId) return; + const prev = projectNetwork; + setProjectNetwork(v); try { const updated = await projectService.update( workspaceSlug, selectedProjectId, - { guest_view_all_features: nextGuestAccess }, + { + network: v === 'public' ? 2 : 0, + }, + ); + setProjects((prevProjects) => + prevProjects.map((p) => (p.id === updated.id ? updated : p)), ); - setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); } catch { - // revert local state on failure - setProjectNetwork(nextGuestAccess ? 'private' : 'public'); - setGuestAccess(!nextGuestAccess); + setProjectNetwork(prev); // revert on failure } }} /> diff --git a/apps/web/src/services/projectService.ts b/apps/web/src/services/projectService.ts index f2b4d157..3b9f9ecc 100644 --- a/apps/web/src/services/projectService.ts +++ b/apps/web/src/services/projectService.ts @@ -61,6 +61,8 @@ export const projectService = { /** When present, use empty string to clear; omit to leave unchanged. */ default_assignee_id?: string; guest_view_all_features?: boolean; + /** 2 = public (any workspace member), 0 = secret (members only). */ + network?: number; module_view?: boolean; cycle_view?: boolean; issue_views_view?: boolean;