tenseleyflow/shithub / 09c93d2

Browse files

Gate private collaboration expansion

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
09c93d295d9ec1276084d9dce16f546ee919a994
Parents
88200be
Tree
178d41c

17 changed files

StatusFile+-
M internal/entitlements/private_collaboration.go 19 1
M internal/entitlements/private_collaboration_test.go 2 1
M internal/orgs/invitations.go 24 0
M internal/orgs/members.go 19 0
A internal/orgs/private_collaboration_test.go 102 0
M internal/orgs/teams.go 15 0
M internal/repos/create.go 10 0
M internal/repos/create_test.go 43 0
M internal/repos/lifecycle/lifecycle_test.go 46 0
M internal/repos/lifecycle/visibility.go 10 0
M internal/web/handlers/api/collaborators.go 11 0
M internal/web/handlers/api/repos.go 7 0
M internal/web/handlers/orgs/orgs.go 23 0
M internal/web/handlers/orgs/teams.go 18 3
M internal/web/handlers/repo/lifecycle.go 3 0
M internal/web/handlers/repo/repo.go 3 0
M internal/web/templates/orgs/people.html 1 0
internal/entitlements/private_collaboration.gomodified
@@ -40,6 +40,24 @@ type PrivateCollaborationCheck struct {
40
 	Reason       Reason
40
 	Reason       Reason
41
 }
41
 }
42
 
42
 
43
+type PrivateCollaborationLimitError struct {
44
+	Check PrivateCollaborationCheck
45
+}
46
+
47
+func (e *PrivateCollaborationLimitError) Error() string {
48
+	if e == nil {
49
+		return ErrPrivateCollaborationLimitExceeded.Error()
50
+	}
51
+	if msg := e.Check.Message(); msg != "" {
52
+		return msg
53
+	}
54
+	return ErrPrivateCollaborationLimitExceeded.Error()
55
+}
56
+
57
+func (e *PrivateCollaborationLimitError) Unwrap() error {
58
+	return ErrPrivateCollaborationLimitExceeded
59
+}
60
+
43
 func PrivateCollaborationUsageForOrg(ctx context.Context, deps Deps, orgID int64) (PrivateCollaborationUsage, error) {
61
 func PrivateCollaborationUsageForOrg(ctx context.Context, deps Deps, orgID int64) (PrivateCollaborationUsage, error) {
44
 	usage, _, err := privateCollaborationUsageWithIDs(ctx, deps, orgID)
62
 	usage, _, err := privateCollaborationUsageWithIDs(ctx, deps, orgID)
45
 	return usage, err
63
 	return usage, err
@@ -198,7 +216,7 @@ func (c PrivateCollaborationCheck) Err() error {
198
 	if c.Allowed {
216
 	if c.Allowed {
199
 		return nil
217
 		return nil
200
 	}
218
 	}
201
-	return ErrPrivateCollaborationLimitExceeded
219
+	return &PrivateCollaborationLimitError{Check: c}
202
 }
220
 }
203
 
221
 
204
 func (c PrivateCollaborationCheck) Message() string {
222
 func (c PrivateCollaborationCheck) Message() string {
internal/entitlements/private_collaboration_test.gomodified
@@ -4,6 +4,7 @@ package entitlements_test
4
 
4
 
5
 import (
5
 import (
6
 	"context"
6
 	"context"
7
+	"errors"
7
 	"strings"
8
 	"strings"
8
 	"testing"
9
 	"testing"
9
 	"time"
10
 	"time"
@@ -88,7 +89,7 @@ func TestPrivateCollaborationExpansionEnforcesFreeLimitAndTeamUnlimited(t *testi
88
 	if err != nil {
89
 	if err != nil {
89
 		t.Fatalf("blocked expansion: %v", err)
90
 		t.Fatalf("blocked expansion: %v", err)
90
 	}
91
 	}
91
-	if check.Allowed || check.WouldUse != 4 || check.Err() != entitlements.ErrPrivateCollaborationLimitExceeded {
92
+	if check.Allowed || check.WouldUse != 4 || !errors.Is(check.Err(), entitlements.ErrPrivateCollaborationLimitExceeded) {
92
 		t.Fatalf("three-user free expansion check = %+v, want blocked", check)
93
 		t.Fatalf("three-user free expansion check = %+v, want blocked", check)
93
 	}
94
 	}
94
 	if !strings.Contains(check.Message(), "up to 3 private collaborators") {
95
 	if !strings.Contains(check.Message(), "up to 3 private collaborators") {
internal/orgs/invitations.gomodified
@@ -14,6 +14,7 @@ import (
14
 
14
 
15
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
15
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/token"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
17
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
18
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
 )
20
 )
@@ -96,6 +97,20 @@ func Invite(ctx context.Context, deps Deps, p InviteParams) (InviteResult, error
96
 	} else if !errors.Is(err, pgx.ErrNoRows) {
97
 	} else if !errors.Is(err, pgx.ErrNoRows) {
97
 		return InviteResult{}, err
98
 		return InviteResult{}, err
98
 	}
99
 	}
100
+	if role == orgsdb.OrgRoleOwner {
101
+		var check entitlements.PrivateCollaborationCheck
102
+		if targetUserID.Valid {
103
+			check, err = entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID, targetUserID.Int64)
104
+		} else {
105
+			check, err = entitlements.CheckPrivateInvitationSlot(ctx, entitlements.Deps{Pool: deps.Pool}, p.OrgID)
106
+		}
107
+		if err != nil {
108
+			return InviteResult{}, err
109
+		}
110
+		if err := check.Err(); err != nil {
111
+			return InviteResult{}, err
112
+		}
113
+	}
99
 
114
 
100
 	tokEnc, tokHash, err := token.New()
115
 	tokEnc, tokHash, err := token.New()
101
 	if err != nil {
116
 	if err != nil {
@@ -186,6 +201,15 @@ func AcceptInvitation(ctx context.Context, deps Deps, inv orgsdb.OrgInvitation,
186
 			return ErrUnauthorizedAcceptor
201
 			return ErrUnauthorizedAcceptor
187
 		}
202
 		}
188
 	}
203
 	}
204
+	if inv.Role == orgsdb.OrgRoleOwner {
205
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, inv.OrgID, acceptorUserID)
206
+		if err != nil {
207
+			return err
208
+		}
209
+		if err := check.Err(); err != nil {
210
+			return err
211
+		}
212
+	}
189
 
213
 
190
 	tx, err := deps.Pool.Begin(ctx)
214
 	tx, err := deps.Pool.Begin(ctx)
191
 	if err != nil {
215
 	if err != nil {
internal/orgs/members.gomodified
@@ -10,6 +10,7 @@ import (
10
 	"github.com/jackc/pgx/v5"
10
 	"github.com/jackc/pgx/v5"
11
 	"github.com/jackc/pgx/v5/pgtype"
11
 	"github.com/jackc/pgx/v5/pgtype"
12
 
12
 
13
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
13
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
14
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
14
 )
15
 )
15
 
16
 
@@ -26,6 +27,15 @@ func AddMember(ctx context.Context, deps Deps, orgID, userID, invitedByUserID in
26
 	if err != nil {
27
 	if err != nil {
27
 		return err
28
 		return err
28
 	}
29
 	}
30
+	if r == orgsdb.OrgRoleOwner {
31
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
32
+		if err != nil {
33
+			return err
34
+		}
35
+		if err := check.Err(); err != nil {
36
+			return err
37
+		}
38
+	}
29
 	tx, err := deps.Pool.Begin(ctx)
39
 	tx, err := deps.Pool.Begin(ctx)
30
 	if err != nil {
40
 	if err != nil {
31
 		return err
41
 		return err
@@ -83,6 +93,15 @@ func ChangeRole(ctx context.Context, deps Deps, orgID, userID int64, role string
83
 			return ErrLastOwner
93
 			return ErrLastOwner
84
 		}
94
 		}
85
 	}
95
 	}
96
+	if current.Role != orgsdb.OrgRoleOwner && r == orgsdb.OrgRoleOwner {
97
+		check, err := entitlements.CheckOrgOwnerPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, orgID, userID)
98
+		if err != nil {
99
+			return err
100
+		}
101
+		if err := check.Err(); err != nil {
102
+			return err
103
+		}
104
+	}
86
 	return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{
105
 	return q.ChangeOrgMemberRole(ctx, deps.Pool, orgsdb.ChangeOrgMemberRoleParams{
87
 		OrgID: orgID, UserID: userID, Role: r,
106
 		OrgID: orgID, UserID: userID, Role: r,
88
 	})
107
 	})
internal/orgs/private_collaboration_test.goadded
@@ -0,0 +1,102 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"testing"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
13
+	"github.com/tenseleyFlow/shithub/internal/orgs"
14
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
15
+)
16
+
17
+func TestOwnerExpansionRespectsPrivateCollaborationLimit(t *testing.T) {
18
+	pool, deps, alice := setup(t)
19
+	ctx := context.Background()
20
+	org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
21
+	if err != nil {
22
+		t.Fatalf("create org: %v", err)
23
+	}
24
+	repo := mustOrgRepo(t, pool, org.ID, "secret", "private")
25
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob"))
26
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol"))
27
+
28
+	dave := mustUser(t, pool, "dave")
29
+	if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
30
+		t.Fatalf("AddMember owner err=%v, want private collaboration limit", err)
31
+	}
32
+	if err := orgs.AddMember(ctx, deps, org.ID, dave, alice, "member"); err != nil {
33
+		t.Fatalf("plain member add should not expand private collaboration: %v", err)
34
+	}
35
+	if err := orgs.ChangeRole(ctx, deps, org.ID, dave, "owner"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
36
+		t.Fatalf("ChangeRole owner err=%v, want private collaboration limit", err)
37
+	}
38
+}
39
+
40
+func TestTeamExpansionRespectsPrivateCollaborationLimit(t *testing.T) {
41
+	pool, deps, alice := setup(t)
42
+	ctx := context.Background()
43
+	org, err := orgs.Create(ctx, deps, orgs.CreateParams{Slug: "acme", CreatedByUserID: alice})
44
+	if err != nil {
45
+		t.Fatalf("create org: %v", err)
46
+	}
47
+	repo := mustOrgRepo(t, pool, org.ID, "secret", "private")
48
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "bob"))
49
+	insertDirectCollab(t, pool, repo.ID, mustUser(t, pool, "carol"))
50
+
51
+	team, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "security", CreatedByUserID: alice})
52
+	if err != nil {
53
+		t.Fatalf("create team: %v", err)
54
+	}
55
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team.ID, repo.ID, alice, "read"); err != nil {
56
+		t.Fatalf("empty-team private grant should not expand private collaboration: %v", err)
57
+	}
58
+	dave := mustUser(t, pool, "dave")
59
+	if err := orgs.AddTeamMember(ctx, deps, team.ID, dave, alice, "member"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
60
+		t.Fatalf("AddTeamMember err=%v, want private collaboration limit", err)
61
+	}
62
+
63
+	team2, err := orgs.CreateTeam(ctx, deps, orgs.CreateTeamParams{OrgID: org.ID, Slug: "ops", CreatedByUserID: alice})
64
+	if err != nil {
65
+		t.Fatalf("create second team: %v", err)
66
+	}
67
+	insertTeamMemberRaw(t, pool, team2.ID, dave)
68
+	if err := orgs.GrantTeamRepoAccess(ctx, deps, team2.ID, repo.ID, alice, "read"); !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
69
+		t.Fatalf("GrantTeamRepoAccess err=%v, want private collaboration limit", err)
70
+	}
71
+	if err := orgs.RemoveTeamMember(ctx, deps, team2.ID, dave); err != nil {
72
+		t.Fatalf("cleanup remove team member should remain allowed: %v", err)
73
+	}
74
+}
75
+
76
+func mustOrgRepo(t *testing.T, db reposdb.DBTX, orgID int64, name, visibility string) reposdb.Repo {
77
+	t.Helper()
78
+	repo, err := reposdb.New().CreateRepo(context.Background(), db, reposdb.CreateRepoParams{
79
+		OwnerOrgID:    pgtype.Int8{Int64: orgID, Valid: true},
80
+		Name:          name,
81
+		Visibility:    reposdb.RepoVisibility(visibility),
82
+		DefaultBranch: "trunk",
83
+	})
84
+	if err != nil {
85
+		t.Fatalf("create org repo %s: %v", name, err)
86
+	}
87
+	return repo
88
+}
89
+
90
+func insertDirectCollab(t *testing.T, db reposdb.DBTX, repoID, userID int64) {
91
+	t.Helper()
92
+	if _, err := db.Exec(context.Background(), `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repoID, userID); err != nil {
93
+		t.Fatalf("insert direct collaborator: %v", err)
94
+	}
95
+}
96
+
97
+func insertTeamMemberRaw(t *testing.T, db reposdb.DBTX, teamID, userID int64) {
98
+	t.Helper()
99
+	if _, err := db.Exec(context.Background(), `INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, 'member')`, teamID, userID); err != nil {
100
+		t.Fatalf("insert team member: %v", err)
101
+	}
102
+}
internal/orgs/teams.gomodified
@@ -13,6 +13,7 @@ import (
13
 	"github.com/jackc/pgx/v5/pgconn"
13
 	"github.com/jackc/pgx/v5/pgconn"
14
 	"github.com/jackc/pgx/v5/pgtype"
14
 	"github.com/jackc/pgx/v5/pgtype"
15
 
15
 
16
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
16
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
17
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
17
 )
18
 )
18
 
19
 
@@ -110,6 +111,13 @@ func AddTeamMember(ctx context.Context, deps Deps, teamID, userID, addedByUserID
110
 	if err != nil {
111
 	if err != nil {
111
 		return err
112
 		return err
112
 	}
113
 	}
114
+	check, err := entitlements.CheckTeamMemberPrivateCollaboration(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, userID)
115
+	if err != nil {
116
+		return err
117
+	}
118
+	if err := check.Err(); err != nil {
119
+		return err
120
+	}
113
 	return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{
121
 	return orgsdb.New().AddTeamMember(ctx, deps.Pool, orgsdb.AddTeamMemberParams{
114
 		TeamID:        teamID,
122
 		TeamID:        teamID,
115
 		UserID:        userID,
123
 		UserID:        userID,
@@ -134,6 +142,13 @@ func GrantTeamRepoAccess(ctx context.Context, deps Deps, teamID, repoID, addedBy
134
 	if err != nil {
142
 	if err != nil {
135
 		return err
143
 		return err
136
 	}
144
 	}
145
+	check, err := entitlements.CheckTeamPrivateRepoGrant(ctx, entitlements.Deps{Pool: deps.Pool}, teamID, repoID)
146
+	if err != nil {
147
+		return err
148
+	}
149
+	if err := check.Err(); err != nil {
150
+		return err
151
+	}
137
 	return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{
152
 	return orgsdb.New().GrantTeamRepoAccess(ctx, deps.Pool, orgsdb.GrantTeamRepoAccessParams{
138
 		TeamID:        teamID,
153
 		TeamID:        teamID,
139
 		RepoID:        repoID,
154
 		RepoID:        repoID,
internal/repos/create.gomodified
@@ -18,6 +18,7 @@ import (
18
 
18
 
19
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
19
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
20
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
20
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
21
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
21
 	"github.com/tenseleyFlow/shithub/internal/git/hooks"
22
 	"github.com/tenseleyFlow/shithub/internal/git/hooks"
22
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
23
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
23
 	"github.com/tenseleyFlow/shithub/internal/issues"
24
 	"github.com/tenseleyFlow/shithub/internal/issues"
@@ -147,6 +148,15 @@ func Create(ctx context.Context, deps Deps, p Params) (Result, error) {
147
 	default:
148
 	default:
148
 		return Result{}, errors.New("repos: owner is XOR — set OwnerUserID OR OwnerOrgID, not both")
149
 		return Result{}, errors.New("repos: owner is XOR — set OwnerUserID OR OwnerOrgID, not both")
149
 	}
150
 	}
151
+	if p.OwnerOrgID != 0 && p.Visibility == "private" {
152
+		check, err := entitlements.CheckPrivateRepositoryCreation(ctx, entitlements.Deps{Pool: deps.Pool}, p.OwnerOrgID)
153
+		if err != nil {
154
+			return Result{}, err
155
+		}
156
+		if err := check.Err(); err != nil {
157
+			return Result{}, err
158
+		}
159
+	}
150
 
160
 
151
 	// Rate-limit per actor (NOT per owner) so a user can't bypass the
161
 	// Rate-limit per actor (NOT per owner) so a user can't bypass the
152
 	// per-account cap by spreading creates across orgs they manage.
162
 	// per-account cap by spreading creates across orgs they manage.
internal/repos/create_test.gomodified
@@ -20,6 +20,7 @@ import (
20
 
20
 
21
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
21
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
22
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
22
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
23
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
23
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
24
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
24
 	"github.com/tenseleyFlow/shithub/internal/orgs"
25
 	"github.com/tenseleyFlow/shithub/internal/orgs"
25
 	"github.com/tenseleyFlow/shithub/internal/repos"
26
 	"github.com/tenseleyFlow/shithub/internal/repos"
@@ -81,6 +82,19 @@ func setupCreateEnv(t *testing.T) (*pgxpool.Pool, repos.Deps, int64, string, str
81
 	return pool, deps, user.ID, user.Username, root
82
 	return pool, deps, user.ID, user.Username, root
82
 }
83
 }
83
 
84
 
85
+func mustCreateRepoUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User {
86
+	t.Helper()
87
+	user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{
88
+		Username:     username,
89
+		DisplayName:  username,
90
+		PasswordHash: fixtureHash,
91
+	})
92
+	if err != nil {
93
+		t.Fatalf("CreateUser %s: %v", username, err)
94
+	}
95
+	return user
96
+}
97
+
84
 func TestCreate_EmptyRepo(t *testing.T) {
98
 func TestCreate_EmptyRepo(t *testing.T) {
85
 	t.Parallel()
99
 	t.Parallel()
86
 	_, deps, uid, uname, root := setupCreateEnv(t)
100
 	_, deps, uid, uname, root := setupCreateEnv(t)
@@ -116,6 +130,35 @@ func TestCreate_EmptyRepo(t *testing.T) {
116
 	}
130
 	}
117
 }
131
 }
118
 
132
 
133
+func TestCreate_PrivateOrgRepoRespectsCollaborationLimit(t *testing.T) {
134
+	t.Parallel()
135
+	pool, deps, uid, _, _ := setupCreateEnv(t)
136
+	ctx := context.Background()
137
+	org, err := orgs.Create(ctx, orgs.Deps{Pool: pool}, orgs.CreateParams{
138
+		Slug:            "acme",
139
+		CreatedByUserID: uid,
140
+	})
141
+	if err != nil {
142
+		t.Fatalf("create org: %v", err)
143
+	}
144
+	for _, name := range []string{"owner2", "owner3", "owner4"} {
145
+		user := mustCreateRepoUser(t, pool, name)
146
+		if _, err := pool.Exec(ctx, `INSERT INTO org_members (org_id, user_id, role) VALUES ($1, $2, 'owner')`, org.ID, user.ID); err != nil {
147
+			t.Fatalf("insert owner: %v", err)
148
+		}
149
+	}
150
+	_, err = repos.Create(ctx, deps, repos.Params{
151
+		OwnerOrgID:  org.ID,
152
+		OwnerSlug:   string(org.Slug),
153
+		ActorUserID: uid,
154
+		Name:        "secret",
155
+		Visibility:  "private",
156
+	})
157
+	if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
158
+		t.Fatalf("Create private org repo err=%v, want private collaboration limit", err)
159
+	}
160
+}
161
+
119
 func TestCreate_WithReadmeLicenseGitignore(t *testing.T) {
162
 func TestCreate_WithReadmeLicenseGitignore(t *testing.T) {
120
 	t.Parallel()
163
 	t.Parallel()
121
 	_, deps, uid, uname, _ := setupCreateEnv(t)
164
 	_, deps, uid, uname, _ := setupCreateEnv(t)
internal/repos/lifecycle/lifecycle_test.gomodified
@@ -14,7 +14,9 @@ import (
14
 
14
 
15
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
17
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
19
+	"github.com/tenseleyFlow/shithub/internal/orgs"
18
 	"github.com/tenseleyFlow/shithub/internal/repos"
20
 	"github.com/tenseleyFlow/shithub/internal/repos"
19
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
21
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
20
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
22
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -87,6 +89,19 @@ func setup(t *testing.T) *env {
87
 	}
89
 	}
88
 }
90
 }
89
 
91
 
92
+func mustLifecycleUser(t *testing.T, db usersdb.DBTX, username string) usersdb.User {
93
+	t.Helper()
94
+	user, err := usersdb.New().CreateUser(context.Background(), db, usersdb.CreateUserParams{
95
+		Username:     username,
96
+		DisplayName:  username,
97
+		PasswordHash: fixtureHash,
98
+	})
99
+	if err != nil {
100
+		t.Fatalf("CreateUser %s: %v", username, err)
101
+	}
102
+	return user
103
+}
104
+
90
 func TestRename_HappyPath(t *testing.T) {
105
 func TestRename_HappyPath(t *testing.T) {
91
 	t.Parallel()
106
 	t.Parallel()
92
 	env := setup(t)
107
 	env := setup(t)
@@ -193,6 +208,37 @@ func TestSetVisibility(t *testing.T) {
193
 	}
208
 	}
194
 }
209
 }
195
 
210
 
211
+func TestSetVisibilityRespectsPrivateCollaborationLimit(t *testing.T) {
212
+	t.Parallel()
213
+	env := setup(t)
214
+	ctx := context.Background()
215
+	org, err := orgs.Create(ctx, orgs.Deps{Pool: env.deps.Pool}, orgs.CreateParams{
216
+		Slug:            "acme",
217
+		CreatedByUserID: env.alice.ID,
218
+	})
219
+	if err != nil {
220
+		t.Fatalf("create org: %v", err)
221
+	}
222
+	repo, err := reposdb.New().CreateRepo(ctx, env.deps.Pool, reposdb.CreateRepoParams{
223
+		OwnerOrgID:    pgtype.Int8{Int64: org.ID, Valid: true},
224
+		Name:          "soon-private",
225
+		DefaultBranch: "trunk",
226
+		Visibility:    reposdb.RepoVisibilityPublic,
227
+	})
228
+	if err != nil {
229
+		t.Fatalf("create org repo: %v", err)
230
+	}
231
+	for _, userID := range []int64{env.bob.ID, mustLifecycleUser(t, env.deps.Pool, "carol").ID, mustLifecycleUser(t, env.deps.Pool, "dave").ID} {
232
+		if _, err := env.deps.Pool.Exec(ctx, `INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')`, repo.ID, userID); err != nil {
233
+			t.Fatalf("insert collaborator: %v", err)
234
+		}
235
+	}
236
+	err = lifecycle.SetVisibility(ctx, env.deps, env.alice.ID, repo.ID, "private")
237
+	if !errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
238
+		t.Fatalf("SetVisibility err=%v, want private collaboration limit", err)
239
+	}
240
+}
241
+
196
 func TestSoftDeleteAndRestore(t *testing.T) {
242
 func TestSoftDeleteAndRestore(t *testing.T) {
197
 	t.Parallel()
243
 	t.Parallel()
198
 	env := setup(t)
244
 	env := setup(t)
internal/repos/lifecycle/visibility.gomodified
@@ -8,6 +8,7 @@ import (
8
 	"fmt"
8
 	"fmt"
9
 
9
 
10
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
10
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
11
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
11
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12
 )
13
 )
13
 
14
 
@@ -35,6 +36,15 @@ func SetVisibility(ctx context.Context, deps Deps, actorUserID, repoID int64, ne
35
 	if string(repo.Visibility) == newVisibility {
36
 	if string(repo.Visibility) == newVisibility {
36
 		return nil // idempotent no-op
37
 		return nil // idempotent no-op
37
 	}
38
 	}
39
+	if repo.OwnerOrgID.Valid && newVisibility == "private" {
40
+		check, err := entitlements.CheckRepoPrivateVisibility(ctx, entitlements.Deps{Pool: deps.Pool}, repo.OwnerOrgID.Int64, repo.ID)
41
+		if err != nil {
42
+			return err
43
+		}
44
+		if err := check.Err(); err != nil {
45
+			return err
46
+		}
47
+	}
38
 
48
 
39
 	if err := rq.SetRepoVisibility(ctx, deps.Pool, reposdb.SetRepoVisibilityParams{
49
 	if err := rq.SetRepoVisibility(ctx, deps.Pool, reposdb.SetRepoVisibilityParams{
40
 		ID:         repoID,
50
 		ID:         repoID,
internal/web/handlers/api/collaborators.gomodified
@@ -15,6 +15,7 @@ import (
15
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
15
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
17
 	policydb "github.com/tenseleyFlow/shithub/internal/auth/policy/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
18
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
 	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
 )
21
 )
@@ -166,6 +167,16 @@ func (h *Handlers) collaboratorPut(w http.ResponseWriter, r *http.Request) {
166
 		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
167
 		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
167
 		return
168
 		return
168
 	}
169
 	}
170
+	check, err := entitlements.CheckDirectPrivateCollaborator(r.Context(), entitlements.Deps{Pool: h.d.Pool}, repo.ID, user.ID)
171
+	if err != nil {
172
+		h.d.Logger.ErrorContext(r.Context(), "api: private collaborator entitlement check", "error", err)
173
+		writeAPIError(w, http.StatusInternalServerError, "collaborator entitlement check failed")
174
+		return
175
+	}
176
+	if err := check.Err(); err != nil {
177
+		writeAPIError(w, http.StatusPaymentRequired, err.Error())
178
+		return
179
+	}
169
 	if err := policydb.New().UpsertCollabRole(r.Context(), h.d.Pool, policydb.UpsertCollabRoleParams{
180
 	if err := policydb.New().UpsertCollabRole(r.Context(), h.d.Pool, policydb.UpsertCollabRoleParams{
170
 		RepoID: repo.ID,
181
 		RepoID: repo.ID,
171
 		UserID: user.ID,
182
 		UserID: user.ID,
internal/web/handlers/api/repos.gomodified
@@ -15,6 +15,7 @@ import (
15
 
15
 
16
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
16
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
17
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
18
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
18
 	"github.com/tenseleyFlow/shithub/internal/orgs"
19
 	"github.com/tenseleyFlow/shithub/internal/orgs"
19
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
20
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
20
 	"github.com/tenseleyFlow/shithub/internal/repos"
21
 	"github.com/tenseleyFlow/shithub/internal/repos"
@@ -423,6 +424,8 @@ func writeRepoCreateError(w http.ResponseWriter, err error) {
423
 		writeAPIError(w, http.StatusConflict, "name taken for owner")
424
 		writeAPIError(w, http.StatusConflict, "name taken for owner")
424
 	case errors.Is(err, repos.ErrNoVerifiedEmail):
425
 	case errors.Is(err, repos.ErrNoVerifiedEmail):
425
 		writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email")
426
 		writeAPIError(w, http.StatusUnprocessableEntity, "actor has no verified primary email")
427
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
428
+		writeAPIError(w, http.StatusPaymentRequired, err.Error())
426
 	default:
429
 	default:
427
 		writeAPIError(w, http.StatusInternalServerError, "create failed")
430
 		writeAPIError(w, http.StatusInternalServerError, "create failed")
428
 	}
431
 	}
@@ -509,6 +512,10 @@ func (h *Handlers) repoPatch(w http.ResponseWriter, r *http.Request) {
509
 			ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
512
 			ldeps := lifecycle.Deps{Pool: h.d.Pool, RepoFS: h.d.RepoFS, Audit: h.d.Audit, Logger: h.d.Logger}
510
 			if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil {
513
 			if err := lifecycle.SetVisibility(r.Context(), ldeps, auth.UserID, repo.ID, newVis); err != nil {
511
 				h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err)
514
 				h.d.Logger.ErrorContext(r.Context(), "api: set visibility", "error", err)
515
+				if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
516
+					writeAPIError(w, http.StatusPaymentRequired, err.Error())
517
+					return
518
+				}
512
 				writeAPIError(w, http.StatusInternalServerError, "visibility update failed")
519
 				writeAPIError(w, http.StatusInternalServerError, "visibility update failed")
513
 				return
520
 				return
514
 			}
521
 			}
internal/web/handlers/orgs/orgs.gomodified
@@ -42,6 +42,7 @@ import (
42
 	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
42
 	authemail "github.com/tenseleyFlow/shithub/internal/auth/email"
43
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
43
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
44
 	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
44
 	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
45
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
45
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
46
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
46
 	"github.com/tenseleyFlow/shithub/internal/orgs"
47
 	"github.com/tenseleyFlow/shithub/internal/orgs"
47
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
48
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -412,11 +413,21 @@ func (h *Handlers) peoplePage(w http.ResponseWriter, r *http.Request) {
412
 		"HasQuery":        query != "",
413
 		"HasQuery":        query != "",
413
 		"IsOwner":         isOwner,
414
 		"IsOwner":         isOwner,
414
 		"CanManagePeople": isOwner,
415
 		"CanManagePeople": isOwner,
416
+		"Notice":          peopleNoticeMessage(r.URL.Query().Get("notice")),
415
 	}); err != nil {
417
 	}); err != nil {
416
 		h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err)
418
 		h.d.Logger.ErrorContext(r.Context(), "orgs: render", "tpl", "orgs/people", "error", err)
417
 	}
419
 	}
418
 }
420
 }
419
 
421
 
422
+func peopleNoticeMessage(code string) string {
423
+	switch code {
424
+	case "private-collab-upgrade":
425
+		return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
426
+	default:
427
+		return ""
428
+	}
429
+}
430
+
420
 func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow {
431
 func filterOrgMembers(members []orgsdb.ListOrgMembersRow, query string) []orgsdb.ListOrgMembersRow {
421
 	query = strings.ToLower(strings.TrimSpace(query))
432
 	query = strings.ToLower(strings.TrimSpace(query))
422
 	if query == "" {
433
 	if query == "" {
@@ -478,6 +489,10 @@ func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
478
 	if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
489
 	if _, err := orgs.Invite(r.Context(), h.deps(), p); err != nil {
479
 		h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
490
 		h.d.Logger.WarnContext(r.Context(), "orgs: invite failed",
480
 			"org", org.Slug, "target", target, "error", err)
491
 			"org", org.Slug, "target", target, "error", err)
492
+		if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
493
+			http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
494
+			return
495
+		}
481
 	}
496
 	}
482
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
497
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
483
 }
498
 }
@@ -530,6 +545,10 @@ func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action f
530
 	if err := action(org.ID, uid); err != nil {
545
 	if err := action(org.ID, uid); err != nil {
531
 		h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
546
 		h.d.Logger.WarnContext(r.Context(), "orgs: member mutation",
532
 			"org", org.Slug, "user_id", uid, "error", err)
547
 			"org", org.Slug, "user_id", uid, "error", err)
548
+		if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
549
+			http.Redirect(w, r, "/"+org.Slug+"/people?notice=private-collab-upgrade", http.StatusSeeOther)
550
+			return
551
+		}
533
 	}
552
 	}
534
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
553
 	http.Redirect(w, r, "/"+org.Slug+"/people", http.StatusSeeOther)
535
 }
554
 }
@@ -591,6 +610,10 @@ func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, acce
591
 		if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
610
 		if err := orgs.AcceptInvitation(r.Context(), h.deps(), inv, viewer.ID); err != nil {
592
 			h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
611
 			h.d.Logger.WarnContext(r.Context(), "orgs: accept invitation",
593
 				"id", inv.ID, "error", err)
612
 				"id", inv.ID, "error", err)
613
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
614
+				h.d.Render.HTTPError(w, r, http.StatusPaymentRequired, "")
615
+				return
616
+			}
594
 			h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
617
 			h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
595
 			return
618
 			return
596
 		}
619
 		}
internal/web/handlers/orgs/teams.gomodified
@@ -4,6 +4,7 @@ package orgs
4
 
4
 
5
 import (
5
 import (
6
 	"context"
6
 	"context"
7
+	"errors"
7
 	"net/http"
8
 	"net/http"
8
 	"net/url"
9
 	"net/url"
9
 	"strconv"
10
 	"strconv"
@@ -191,6 +192,8 @@ func teamsNoticeMessage(code string) string {
191
 		return "Secret teams are read-only until Team billing is brought back into good standing."
192
 		return "Secret teams are read-only until Team billing is brought back into good standing."
192
 	case "secret-teams-enterprise":
193
 	case "secret-teams-enterprise":
193
 		return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them."
194
 		return "Secret teams are unavailable for Enterprise preview organizations. Contact sales to enable them."
195
+	case "private-collab-upgrade":
196
+		return "Free organizations can have up to 3 private collaborators. Upgrade to Team to add more private collaborators."
194
 	default:
197
 	default:
195
 		return ""
198
 		return ""
196
 	}
199
 	}
@@ -335,7 +338,13 @@ func (h *Handlers) teamMemberAddRemove(w http.ResponseWriter, r *http.Request) {
335
 			return
338
 			return
336
 		}
339
 		}
337
 		role := r.PostFormValue("role")
340
 		role := r.PostFormValue("role")
338
-		_ = orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role)
341
+		if err := orgs.AddTeamMember(r.Context(), h.deps(), team.ID, uid, viewer.ID, role); err != nil {
342
+			h.d.Logger.WarnContext(r.Context(), "teams: add member", "org_id", org.ID, "team_id", team.ID, "user_id", uid, "error", err)
343
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
344
+				http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
345
+				return
346
+			}
347
+		}
339
 	}
348
 	}
340
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
349
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
341
 }
350
 }
@@ -381,8 +390,14 @@ func (h *Handlers) teamRepoGrant(w http.ResponseWriter, r *http.Request) {
381
 			http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
390
 			http.Redirect(w, r, h.teamPath(org, team)+"?notice="+noticeCode, http.StatusSeeOther)
382
 			return
391
 			return
383
 		}
392
 		}
384
-		_ = orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
393
+		if err := orgs.GrantTeamRepoAccess(r.Context(), h.deps(), team.ID, repoID, viewer.ID,
385
-			r.PostFormValue("role"))
394
+			r.PostFormValue("role")); err != nil {
395
+			h.d.Logger.WarnContext(r.Context(), "teams: grant repo", "org_id", org.ID, "team_id", team.ID, "repo_id", repoID, "error", err)
396
+			if errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded) {
397
+				http.Redirect(w, r, h.teamPath(org, team)+"?notice=private-collab-upgrade", http.StatusSeeOther)
398
+				return
399
+			}
400
+		}
386
 	}
401
 	}
387
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
402
 	http.Redirect(w, r, h.teamPath(org, team), http.StatusSeeOther)
388
 }
403
 }
internal/web/handlers/repo/lifecycle.gomodified
@@ -12,6 +12,7 @@ import (
12
 	"github.com/jackc/pgx/v5/pgtype"
12
 	"github.com/jackc/pgx/v5/pgtype"
13
 
13
 
14
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
14
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
15
 	"github.com/tenseleyFlow/shithub/internal/orgs"
16
 	"github.com/tenseleyFlow/shithub/internal/orgs"
16
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
17
 	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
17
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
18
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -406,6 +407,8 @@ func (h *Handlers) lifecycleError(w http.ResponseWriter, r *http.Request, err er
406
 		http.Error(w, "transfer no longer pending", http.StatusConflict)
407
 		http.Error(w, "transfer no longer pending", http.StatusConflict)
407
 	case errors.Is(err, lifecycle.ErrPastGrace):
408
 	case errors.Is(err, lifecycle.ErrPastGrace):
408
 		http.Error(w, "soft-delete grace expired", http.StatusGone)
409
 		http.Error(w, "soft-delete grace expired", http.StatusGone)
410
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
411
+		http.Error(w, err.Error(), http.StatusPaymentRequired)
409
 	default:
412
 	default:
410
 		h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err)
413
 		h.d.Logger.WarnContext(r.Context(), "lifecycle: unexpected error", "error", err)
411
 		http.Error(w, "internal error", http.StatusInternalServerError)
414
 		http.Error(w, "internal error", http.StatusInternalServerError)
internal/web/handlers/repo/repo.gomodified
@@ -24,6 +24,7 @@ import (
24
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
24
 	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
25
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
25
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
26
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
26
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
27
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
27
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
28
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
28
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
29
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
29
 	"github.com/tenseleyFlow/shithub/internal/orgs"
30
 	"github.com/tenseleyFlow/shithub/internal/orgs"
@@ -559,6 +560,8 @@ func friendlyCreateError(err error) string {
559
 		return "Unknown license selection."
560
 		return "Unknown license selection."
560
 	case errors.Is(err, repos.ErrUnknownGitignore):
561
 	case errors.Is(err, repos.ErrUnknownGitignore):
561
 		return "Unknown .gitignore selection."
562
 		return "Unknown .gitignore selection."
563
+	case errors.Is(err, entitlements.ErrPrivateCollaborationLimitExceeded):
564
+		return err.Error()
562
 	}
565
 	}
563
 	if t, ok := isThrottled(err); ok {
566
 	if t, ok := isThrottled(err); ok {
564
 		return "You're creating repositories too quickly. Try again in " + t + "."
567
 		return "You're creating repositories too quickly. Try again in " + t + "."
internal/web/templates/orgs/people.htmlmodified
@@ -28,6 +28,7 @@
28
     </aside>
28
     </aside>
29
 
29
 
30
     <div class="shithub-org-people-main">
30
     <div class="shithub-org-people-main">
31
+      {{ with .Notice }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
31
       <div class="shithub-org-people-toolbar">
32
       <div class="shithub-org-people-toolbar">
32
         <form method="GET" action="/{{ .Org.Slug }}/people" class="shithub-org-people-search" role="search">
33
         <form method="GET" action="/{{ .Org.Slug }}/people" class="shithub-org-people-search" role="search">
33
           {{ octicon "search" }}
34
           {{ octicon "search" }}