Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
19 changes: 17 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 All @@ -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 {
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -207,6 +216,7 @@ func (h *ProjectHandler) Create(c *gin.Context) {
defaultAssigneeSet,
defaultAssigneeIDPtr,
body.GuestViewAllFeatures,
body.Network,
body.ModuleView,
body.CycleView,
body.IssueViewsView,
Expand All @@ -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
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down
99 changes: 99 additions & 0 deletions apps/api/internal/handler/project_network_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
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