Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ModuleView *bool `json:"module_view"`
CycleView *bool `json:"cycle_view"`
IssueViewsView *bool `json:"issue_views_view"`
Expand Down Expand Up @@ -133,6 +134,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 ||
Expand Down Expand Up @@ -207,6 +209,7 @@ func (h *ProjectHandler) Create(c *gin.Context) {
defaultAssigneeSet,
defaultAssigneeIDPtr,
body.GuestViewAllFeatures,
body.Network,
body.ModuleView,
body.CycleView,
body.IssueViewsView,
Expand All @@ -215,6 +218,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
Expand Down Expand Up @@ -250,6 +257,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"`
Expand Down Expand Up @@ -313,13 +321,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
}
Expand Down
70 changes: 70 additions & 0 deletions apps/api/internal/handler/project_network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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)
}
8 changes: 8 additions & 0 deletions apps/api/internal/model/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
36 changes: 33 additions & 3 deletions apps/api/internal/service/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions apps/api/internal/store/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
23 changes: 12 additions & 11 deletions apps/web/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -1713,22 +1714,22 @@ export function SettingsPage() {
<ProjectNetworkSelect
value={projectNetwork}
onChange={async (v) => {
// 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
}
}}
/>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/services/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading