mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-15 20:25:18 +02:00
feat(org): add team visibility so org members can discover teams (#37680)
Closes #37670. Today, org members in Gitea only see teams they're a member of. In larger orgs that hurts onboarding and discoverability — there's no way to look up which team owns what without asking around. GitHub solves this with a per-team visibility setting; this PR brings the same model to Gitea. ## What changes - Every team gets a `visibility` setting: - `private` *(default)* — only team members and org owners can see the team. Same as today's behavior. - `limited` — listable by any member of the organization. Members and the repos the team has access to are visible too. Non-org-members still see nothing. - `public` — listable by any signed-in user. - The Owners team visibility is fixed and cannot be changed via settings. - Existing teams default to `private`, so this is a no-op for anyone who doesn't change anything. ## API - `Team`, `CreateTeamOption`, `EditTeamOption` all gain a `visibility` field (string enum: `private` | `limited` | `public`). - `GET /orgs/{org}/teams` and `/orgs/{org}/teams/search` now apply the same visibility rules as the web UI: - site admins and org owners still see every team - other org members see their own teams plus any `limited` or `public` team - `private` teams are no longer leaked through these endpoints - Swagger/OpenAPI specs regenerated. ## UI View from admin2 (not an owner): <img width="1669" height="726" src="https://github.com/user-attachments/assets/daf4bccb-644b-4426-b178-71963aeaf73b" /> View from admin (owner): <img width="2559" height="863" src="https://github.com/user-attachments/assets/4f22cebc-e9df-4fd2-8ed4-724d31fadb7a" /> --------- Signed-off-by: bircni <bircni@icloud.com> Co-authored-by: TheFox0x7 <thefox0x7@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -57,8 +57,8 @@ func main() {
|
|||||||
log.Fatalf("scanning swagger:enum annotations: %v", err)
|
log.Fatalf("scanning swagger:enum annotations: %v", err)
|
||||||
}
|
}
|
||||||
names := make([]string, 0, len(astEnumMap))
|
names := make([]string, 0, len(astEnumMap))
|
||||||
for _, n := range astEnumMap {
|
for _, ns := range astEnumMap {
|
||||||
names = append(names, n)
|
names = append(names, ns...)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))
|
fmt.Fprintf(os.Stderr, "discovered %d swagger:enum types: %s\n", len(names), strings.Join(names, ", "))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package openapi3gen
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/modules/json"
|
"gitea.dev/modules/json"
|
||||||
@@ -25,10 +26,12 @@ var rxDeprecated = regexp.MustCompile(`(?i)(?:^|[\n.;])\s*deprecated\b`)
|
|||||||
// Gitea-specific post-processing: file-schema fixups, URI formats,
|
// Gitea-specific post-processing: file-schema fixups, URI formats,
|
||||||
// deprecated flags, and shared-enum extraction.
|
// deprecated flags, and shared-enum extraction.
|
||||||
//
|
//
|
||||||
// astEnumMap is a value-set-key → Go-type-name map (built by
|
// astEnumMap is a value-set-key → Go-type-name(s) map (built by
|
||||||
// ScanSwaggerEnumTypes). If a shared enum in the spec has no entry in the
|
// ScanSwaggerEnumTypes). When a value set is shared by multiple Go types,
|
||||||
// map, Convert returns an error — no fallback naming.
|
// per-property disambiguation uses the x-go-enum-desc extension. If a shared
|
||||||
func Convert(swaggerJSON []byte, astEnumMap map[string]string) (*openapi3.T, error) {
|
// enum in the spec has no matching entry, Convert returns an error — no
|
||||||
|
// fallback naming.
|
||||||
|
func Convert(swaggerJSON []byte, astEnumMap map[string][]string) (*openapi3.T, error) {
|
||||||
var swagger2 openapi2.T
|
var swagger2 openapi2.T
|
||||||
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
|
if err := json.Unmarshal(swaggerJSON, &swagger2); err != nil {
|
||||||
return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
|
return nil, fmt.Errorf("parsing swagger 2.0: %w", err)
|
||||||
@@ -176,12 +179,24 @@ type enumUsage struct {
|
|||||||
// If the derived enum name collides with an existing component schema, or
|
// If the derived enum name collides with an existing component schema, or
|
||||||
// no // swagger:enum annotation matches the value set, generation aborts
|
// no // swagger:enum annotation matches the value set, generation aborts
|
||||||
// with an actionable error — there are no silent fallbacks.
|
// with an actionable error — there are no silent fallbacks.
|
||||||
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
|
func extractSharedEnums(doc *openapi3.T, astEnumMap map[string][]string) error {
|
||||||
if doc.Components == nil {
|
if doc.Components == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
enumGroups := map[string][]enumUsage{}
|
type groupKey struct {
|
||||||
|
valueSet string
|
||||||
|
typeName string
|
||||||
|
}
|
||||||
|
enumGroups := map[groupKey][]enumUsage{}
|
||||||
|
groupOrder := []groupKey{} // deterministic iteration
|
||||||
|
|
||||||
|
addUsage := func(key groupKey, u enumUsage) {
|
||||||
|
if _, seen := enumGroups[key]; !seen {
|
||||||
|
groupOrder = append(groupOrder, key)
|
||||||
|
}
|
||||||
|
enumGroups[key] = append(enumGroups[key], u)
|
||||||
|
}
|
||||||
|
|
||||||
for schemaName, schemaRef := range doc.Components.Schemas {
|
for schemaName, schemaRef := range doc.Components.Schemas {
|
||||||
if schemaRef.Value == nil {
|
if schemaRef.Value == nil {
|
||||||
@@ -192,24 +207,31 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
|
if len(propRef.Value.Enum) > 1 && propRef.Value.Type.Is("string") {
|
||||||
key := EnumKey(propRef.Value.Enum)
|
key := groupKey{
|
||||||
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, false})
|
valueSet: EnumKey(propRef.Value.Enum),
|
||||||
|
typeName: extractEnumTypeName(propRef.Value, astEnumMap),
|
||||||
|
}
|
||||||
|
addUsage(key, enumUsage{schemaName, propName, propRef, false})
|
||||||
}
|
}
|
||||||
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
|
if propRef.Value.Type.Is("array") && propRef.Value.Items != nil &&
|
||||||
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
|
propRef.Value.Items.Value != nil && propRef.Value.Items.Ref == "" &&
|
||||||
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
|
len(propRef.Value.Items.Value.Enum) > 1 && propRef.Value.Items.Value.Type.Is("string") {
|
||||||
key := EnumKey(propRef.Value.Items.Value.Enum)
|
key := groupKey{
|
||||||
enumGroups[key] = append(enumGroups[key], enumUsage{schemaName, propName, propRef, true})
|
valueSet: EnumKey(propRef.Value.Items.Value.Enum),
|
||||||
|
typeName: extractEnumTypeName(propRef.Value.Items.Value, astEnumMap),
|
||||||
|
}
|
||||||
|
addUsage(key, enumUsage{schemaName, propName, propRef, true})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, usages := range enumGroups {
|
for _, key := range groupOrder {
|
||||||
|
usages := enumGroups[key]
|
||||||
if len(usages) < 2 {
|
if len(usages) < 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
enumName, err := deriveEnumName(key, usages, astEnumMap)
|
enumName, err := deriveEnumName(key.valueSet, key.typeName, usages, astEnumMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -257,12 +279,17 @@ func extractSharedEnums(doc *openapi3.T, astEnumMap map[string]string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveEnumName looks up a shared enum's Go type name from astEnumMap by
|
// deriveEnumName looks up a shared enum's Go type name. If typeName is
|
||||||
// value-set key. If no annotation matches, returns an error identifying the
|
// non-empty (because we recovered it from x-go-enum-desc), it is used
|
||||||
// offending properties and the fix.
|
// directly. Otherwise the value-set must map to exactly one known type. On
|
||||||
func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string) (string, error) {
|
// failure, returns an error identifying the offending properties.
|
||||||
if name, ok := astEnumMap[key]; ok {
|
func deriveEnumName(key, typeName string, usages []enumUsage, astEnumMap map[string][]string) (string, error) {
|
||||||
return name, nil
|
if typeName != "" {
|
||||||
|
return typeName, nil
|
||||||
|
}
|
||||||
|
names := astEnumMap[key]
|
||||||
|
if len(names) == 1 {
|
||||||
|
return names[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
props := map[string]bool{}
|
props := map[string]bool{}
|
||||||
@@ -273,9 +300,87 @@ func deriveEnumName(key string, usages []enumUsage, astEnumMap map[string]string
|
|||||||
for p := range props {
|
for p := range props {
|
||||||
propList = append(propList, p)
|
propList = append(propList, p)
|
||||||
}
|
}
|
||||||
|
if len(names) > 1 {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"value-set %q is shared by multiple swagger:enum types %v and could not be disambiguated for properties: %v; "+
|
||||||
|
"ensure go-swagger emits x-go-enum-desc for those properties",
|
||||||
|
key, names, propList,
|
||||||
|
)
|
||||||
|
}
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
|
"no swagger:enum annotation matches value-set %q used by %d properties: %v; "+
|
||||||
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
|
"fix by adding a named string type with // swagger:enum to modules/structs or modules/commitstatus",
|
||||||
key, len(usages), propList,
|
key, len(usages), propList,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractEnumTypeName recovers the Go type name a schema's enum came from by
|
||||||
|
// parsing the property's x-go-enum-desc extension. go-swagger emits one line
|
||||||
|
// per value as "<value> <ConstName>[ <free text>]"; the type is the longest
|
||||||
|
// common prefix of the const names, narrowed to the candidate set in
|
||||||
|
// astEnumMap. Returns "" if extraction is inconclusive.
|
||||||
|
func extractEnumTypeName(s *openapi3.Schema, astEnumMap map[string][]string) string {
|
||||||
|
if s == nil || s.Extensions == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, ok := s.Extensions["x-go-enum-desc"]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
desc, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
candidates := astEnumMap[EnumKey(s.Enum)]
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Collect the const names (second whitespace-separated field per line).
|
||||||
|
var consts []string
|
||||||
|
for line := range strings.SplitSeq(desc, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
consts = append(consts, fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(consts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// A candidate matches when it is a prefix of every const name AND the
|
||||||
|
// first character after the prefix is an uppercase ASCII letter — this
|
||||||
|
// rejects e.g. "Alpha" matching "Alphabet" (suffix "bet" starts lower)
|
||||||
|
// while still accepting both "Alpha" and "AlphaPlus" against "AlphaPlusX"
|
||||||
|
// (both prefixes valid). The most specific (longest) wins; ties return
|
||||||
|
// "" so deriveEnumName surfaces the ambiguity rather than silently
|
||||||
|
// picking a winner.
|
||||||
|
ordered := append([]string(nil), candidates...)
|
||||||
|
sort.Slice(ordered, func(i, j int) bool { return len(ordered[i]) > len(ordered[j]) })
|
||||||
|
var matches []string
|
||||||
|
for _, name := range ordered {
|
||||||
|
ok := true
|
||||||
|
for _, c := range consts {
|
||||||
|
if !strings.HasPrefix(c, name) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
suffix := c[len(name):]
|
||||||
|
// Empty suffix means the const name exactly equals the type name — valid exact match.
|
||||||
|
// A non-empty suffix must begin with an uppercase letter to reject incidental
|
||||||
|
// prefix matches (e.g. "Alpha" should not match "Alphabet").
|
||||||
|
if len(suffix) > 0 && (suffix[0] < 'A' || suffix[0] > 'Z') {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
matches = append(matches, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(matches) > 1 && len(matches[0]) == len(matches[1]) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return matches[0]
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
func TestDeriveEnumName_hit(t *testing.T) {
|
func TestDeriveEnumName_hit(t *testing.T) {
|
||||||
key := EnumKey([]any{"red", "green", "blue"})
|
key := EnumKey([]any{"red", "green", "blue"})
|
||||||
astMap := map[string]string{key: "Color"}
|
astMap := map[string][]string{key: {"Color"}}
|
||||||
usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
|
usages := []enumUsage{{schemaName: "Paint", propName: "color"}}
|
||||||
got, err := deriveEnumName(key, usages, astMap)
|
got, err := deriveEnumName(key, "", usages, astMap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func TestDeriveEnumName_hit(t *testing.T) {
|
|||||||
func TestDeriveEnumName_miss(t *testing.T) {
|
func TestDeriveEnumName_miss(t *testing.T) {
|
||||||
key := EnumKey([]any{"x", "y"})
|
key := EnumKey([]any{"x", "y"})
|
||||||
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
|
usages := []enumUsage{{schemaName: "Thing", propName: "kind"}}
|
||||||
_, err := deriveEnumName(key, usages, map[string]string{})
|
_, err := deriveEnumName(key, "", usages, map[string][]string{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected miss error, got nil")
|
t.Fatal("expected miss error, got nil")
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func TestExtractSharedEnums_usesASTMap(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
astMap := map[string]string{EnumKey([]any{"red", "green", "blue"}): "Color"}
|
astMap := map[string][]string{EnumKey([]any{"red", "green", "blue"}): {"Color"}}
|
||||||
if err := extractSharedEnums(doc, astMap); err != nil {
|
if err := extractSharedEnums(doc, astMap); err != nil {
|
||||||
t.Fatalf("extractSharedEnums: %v", err)
|
t.Fatalf("extractSharedEnums: %v", err)
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,54 @@ func TestFixFileSchemas_recursesIntoNested(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractEnumTypeName_TeamVisibility(t *testing.T) {
|
||||||
|
enum := []any{"public", "limited", "private"}
|
||||||
|
key := EnumKey(enum)
|
||||||
|
astMap := map[string][]string{key: {"UserVisibility", "TeamVisibility"}}
|
||||||
|
schema := &openapi3.Schema{
|
||||||
|
Type: &openapi3.Types{"string"},
|
||||||
|
Enum: enum,
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := extractEnumTypeName(schema, astMap); got != "TeamVisibility" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "TeamVisibility")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEnumTypeName_ambiguousPrefixTie(t *testing.T) {
|
||||||
|
enum := []any{"one", "two"}
|
||||||
|
key := EnumKey(enum)
|
||||||
|
astMap := map[string][]string{key: {"AB", "AC"}}
|
||||||
|
schema := &openapi3.Schema{
|
||||||
|
Type: &openapi3.Types{"string"},
|
||||||
|
Enum: enum,
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"x-go-enum-desc": "one ABOne\ntwo ACTwo",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := extractEnumTypeName(schema, astMap); got != "" {
|
||||||
|
t.Fatalf("got %q, want empty string for ambiguous tie", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEnumTypeName_rejectsIncidentalPrefix(t *testing.T) {
|
||||||
|
enum := []any{"a", "b"}
|
||||||
|
key := EnumKey(enum)
|
||||||
|
astMap := map[string][]string{key: {"Alpha", "Alphabet"}}
|
||||||
|
schema := &openapi3.Schema{
|
||||||
|
Type: &openapi3.Types{"string"},
|
||||||
|
Enum: enum,
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"x-go-enum-desc": "a AlphabetA\nb AlphabetB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := extractEnumTypeName(schema, astMap); got != "Alphabet" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "Alphabet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractSharedEnums_missReturnsError(t *testing.T) {
|
func TestExtractSharedEnums_missReturnsError(t *testing.T) {
|
||||||
doc := &openapi3.T{
|
doc := &openapi3.T{
|
||||||
Components: &openapi3.Components{
|
Components: &openapi3.Components{
|
||||||
@@ -164,7 +212,7 @@ func TestExtractSharedEnums_missReturnsError(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := extractSharedEnums(doc, map[string]string{}); err == nil {
|
if err := extractSharedEnums(doc, map[string][]string{}); err == nil {
|
||||||
t.Fatal("expected miss error")
|
t.Fatal("expected miss error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ func EnumKey(values []any) string {
|
|||||||
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
|
var rxSwaggerEnum = regexp.MustCompile(`swagger:enum\s+(\w+)`)
|
||||||
|
|
||||||
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
|
// ScanSwaggerEnumTypes walks .go files under each dir and returns a map from
|
||||||
// a canonical value-set key (see EnumKey) to the Go type name declared with
|
// a canonical value-set key (see EnumKey) to the Go type names declared with
|
||||||
// // swagger:enum TypeName.
|
// // swagger:enum TypeName. Multiple type names per key are allowed (e.g.
|
||||||
|
// distinct enum types that happen to share a value set such as
|
||||||
|
// {public, limited, private}); callers must disambiguate per-usage (typically
|
||||||
|
// by parsing the property's x-go-enum-desc extension to recover the const
|
||||||
|
// type prefix).
|
||||||
//
|
//
|
||||||
// Returns an error on parse failure, on an annotation for a type whose
|
// Returns an error on parse failure or on an annotation for a type whose
|
||||||
// constants can't be extracted, or on value-set collisions between two
|
// constants can't be extracted.
|
||||||
// different enum types.
|
func ScanSwaggerEnumTypes(dirs []string) (map[string][]string, error) {
|
||||||
func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
|
|
||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
parsed := []*ast.File{}
|
parsed := []*ast.File{}
|
||||||
|
|
||||||
@@ -92,17 +95,18 @@ func ScanSwaggerEnumTypes(dirs []string) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{}
|
result := map[string][]string{}
|
||||||
for typeName := range enumTypes {
|
for typeName := range enumTypes {
|
||||||
values, ok := enumValues[typeName]
|
values, ok := enumValues[typeName]
|
||||||
if !ok || len(values) == 0 {
|
if !ok || len(values) == 0 {
|
||||||
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
|
return nil, fmt.Errorf("swagger:enum %s has no const block with typed string values", typeName)
|
||||||
}
|
}
|
||||||
key := EnumKey(values)
|
key := EnumKey(values)
|
||||||
if existing, ok := result[key]; ok && existing != typeName {
|
result[key] = append(result[key], typeName)
|
||||||
return nil, fmt.Errorf("swagger:enum value-set collision: %s and %s both use %q", existing, typeName, key)
|
}
|
||||||
}
|
for key, names := range result {
|
||||||
result[key] = typeName
|
sort.Strings(names)
|
||||||
|
result[key] = names
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ package openapi3gen
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func single(got map[string][]string, key string) string {
|
||||||
|
v := got[key]
|
||||||
|
if len(v) != 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnumKey_sortsAndJoins(t *testing.T) {
|
func TestEnumKey_sortsAndJoins(t *testing.T) {
|
||||||
key := EnumKey([]any{"b", "a", "c"})
|
key := EnumKey([]any{"b", "a", "c"})
|
||||||
if key != "a|b|c" {
|
if key != "a|b|c" {
|
||||||
@@ -47,7 +56,7 @@ const (
|
|||||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||||
}
|
}
|
||||||
wantKey := EnumKey([]any{"red", "green", "blue"})
|
wantKey := EnumKey([]any{"red", "green", "blue"})
|
||||||
if got[wantKey] != "Color" {
|
if single(got, wantKey) != "Color" {
|
||||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
|
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Color")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,13 +107,14 @@ const (
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := ScanSwaggerEnumTypes([]string{dir})
|
got, err := ScanSwaggerEnumTypes([]string{dir})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected collision error, got nil")
|
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||||
}
|
}
|
||||||
msg := err.Error()
|
key := EnumKey([]any{"x", "y"})
|
||||||
if !strings.Contains(msg, "Alpha") || !strings.Contains(msg, "Beta") {
|
names := got[key]
|
||||||
t.Fatalf("error %q should mention both Alpha and Beta", msg)
|
if !slices.Equal(names, []string{"Alpha", "Beta"}) {
|
||||||
|
t.Fatalf("map[%q] = %v, want [Alpha Beta]", key, names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +178,7 @@ type Hue string
|
|||||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||||
}
|
}
|
||||||
wantKey := EnumKey([]any{"a", "b"})
|
wantKey := EnumKey([]any{"a", "b"})
|
||||||
if got[wantKey] != "Hue" {
|
if single(got, wantKey) != "Hue" {
|
||||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
|
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Hue")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +204,7 @@ type Shade string
|
|||||||
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
t.Fatalf("ScanSwaggerEnumTypes: %v", err)
|
||||||
}
|
}
|
||||||
wantKey := EnumKey([]any{"dark", "light"})
|
wantKey := EnumKey([]any{"dark", "light"})
|
||||||
if got[wantKey] != "Shade" {
|
if single(got, wantKey) != "Shade" {
|
||||||
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
|
t.Fatalf("map[%q] = %q, want %q", wantKey, got[wantKey], "Shade")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,10 +240,10 @@ const (
|
|||||||
}
|
}
|
||||||
colorKey := EnumKey([]any{"red", "blue"})
|
colorKey := EnumKey([]any{"red", "blue"})
|
||||||
shadeKey := EnumKey([]any{"dark", "light"})
|
shadeKey := EnumKey([]any{"dark", "light"})
|
||||||
if got[colorKey] != "Color" {
|
if single(got, colorKey) != "Color" {
|
||||||
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
|
t.Fatalf("Color: map[%q] = %q, want %q", colorKey, got[colorKey], "Color")
|
||||||
}
|
}
|
||||||
if got[shadeKey] != "Shade" {
|
if single(got, shadeKey) != "Shade" {
|
||||||
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
|
t.Fatalf("Shade: map[%q] = %q, want %q", shadeKey, got[shadeKey], "Shade")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ var (
|
|||||||
registeredInitFuncs []func() error
|
registeredInitFuncs []func() error
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine represents a xorm engine or session.
|
// SQLSession represents a common interface for engine and session to execute SQLs
|
||||||
type Engine interface {
|
type SQLSession interface {
|
||||||
Table(tableNameOrBean any) *xorm.Session
|
|
||||||
Count(...any) (int64, error)
|
Count(...any) (int64, error)
|
||||||
Decr(column string, arg ...any) *xorm.Session
|
Decr(column string, arg ...any) *xorm.Session
|
||||||
Delete(...any) (int64, error)
|
Delete(...any) (int64, error)
|
||||||
@@ -52,7 +51,6 @@ type Engine interface {
|
|||||||
Limit(limit int, start ...int) *xorm.Session
|
Limit(limit int, start ...int) *xorm.Session
|
||||||
NoAutoTime() *xorm.Session
|
NoAutoTime() *xorm.Session
|
||||||
SumInt(bean any, columnName string) (res int64, err error)
|
SumInt(bean any, columnName string) (res int64, err error)
|
||||||
Sync(...any) error
|
|
||||||
Select(string) *xorm.Session
|
Select(string) *xorm.Session
|
||||||
SetExpr(string, any) *xorm.Session
|
SetExpr(string, any) *xorm.Session
|
||||||
NotIn(string, ...any) *xorm.Session
|
NotIn(string, ...any) *xorm.Session
|
||||||
@@ -61,12 +59,20 @@ type Engine interface {
|
|||||||
Distinct(...string) *xorm.Session
|
Distinct(...string) *xorm.Session
|
||||||
Query(...any) ([]map[string][]byte, error)
|
Query(...any) ([]map[string][]byte, error)
|
||||||
Cols(...string) *xorm.Session
|
Cols(...string) *xorm.Session
|
||||||
|
Table(tableNameOrBean any) *xorm.Session
|
||||||
Context(ctx context.Context) *xorm.Session
|
Context(ctx context.Context) *xorm.Session
|
||||||
Ping() error
|
QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
|
||||||
IsTableExist(tableNameOrBean any) (bool, error)
|
IsTableExist(tableNameOrBean any) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session represents a xorm session interface, used as an abstraction over *xorm.Session.
|
// Engine represents a xorm engine
|
||||||
|
type Engine interface {
|
||||||
|
SQLSession
|
||||||
|
Sync(...any) error
|
||||||
|
Ping() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a xorm session interface
|
||||||
type Session interface {
|
type Session interface {
|
||||||
Engine
|
Engine
|
||||||
And(query any, args ...any) *xorm.Session
|
And(query any, args ...any) *xorm.Session
|
||||||
@@ -89,7 +95,6 @@ type EngineMigration interface {
|
|||||||
Dialect() dialects.Dialect
|
Dialect() dialects.Dialect
|
||||||
DropTables(beans ...any) error
|
DropTables(beans ...any) error
|
||||||
NewSession() *xorm.Session
|
NewSession() *xorm.Session
|
||||||
QueryInterface(sqlOrArgs ...any) ([]map[string]any, error)
|
|
||||||
SetMapper(mapper names.Mapper)
|
SetMapper(mapper names.Mapper)
|
||||||
SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error)
|
SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error)
|
||||||
TableInfo(bean any) (*schemas.Table, error)
|
TableInfo(bean any) (*schemas.Table, error)
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ type Paginator interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetSessionPagination sets pagination for a database session
|
// SetSessionPagination sets pagination for a database session
|
||||||
func SetSessionPagination(sess Engine, p Paginator) Session {
|
func SetSessionPagination(sess Engine, p Paginator) {
|
||||||
skip, take := p.GetSkipTake()
|
skip, take := p.GetSkipTake()
|
||||||
|
sess.Limit(take, skip)
|
||||||
return sess.Limit(take, skip)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListOptions options to paginate results
|
// ListOptions options to paginate results
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func PullRequests(ctx context.Context, baseRepoID int64, opts *PullRequestsOptio
|
|||||||
|
|
||||||
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
|
findSession := listPullRequestStatement(ctx, baseRepoID, opts)
|
||||||
applySorts(findSession, opts.SortType, 0)
|
applySorts(findSession, opts.SortType, 0)
|
||||||
findSession = db.SetSessionPagination(findSession, opts)
|
db.SetSessionPagination(findSession, opts)
|
||||||
prs := make([]*PullRequest, 0, opts.PageSize)
|
prs := make([]*PullRequest, 0, opts.PageSize)
|
||||||
found := findSession.Find(&prs)
|
found := findSession.Find(&prs)
|
||||||
return prs, maxResults, found
|
return prs, maxResults, found
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
newMigration(334, "Add cancelling support to action runners", v1_27.AddCancellingSupportToActionRunner),
|
||||||
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
|
newMigration(335, "Add reusable workflow fields and action_run_attempt_job_id_index table for ActionRunJob", v1_27.AddReusableWorkflowFieldsToActionRunJob),
|
||||||
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
|
newMigration(336, "Add ActionRunJobSummary table", v1_27.AddActionRunJobSummaryTable),
|
||||||
|
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
36
models/migrations/v1_27/v337.go
Normal file
36
models/migrations/v1_27/v337.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_27
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VisibleType int
|
||||||
|
|
||||||
|
type teamWithVisibility struct {
|
||||||
|
Visibility VisibleType `xorm:"NOT NULL DEFAULT 2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (teamWithVisibility) TableName() string {
|
||||||
|
return "team"
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddVisibilityToTeam(x db.EngineMigration) error {
|
||||||
|
if _, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||||
|
IgnoreDropIndices: true,
|
||||||
|
IgnoreConstrains: true,
|
||||||
|
}, new(teamWithVisibility)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner teams must remain listable to all org members; new orgs create
|
||||||
|
// them as "limited", so make existing owner teams limited too.
|
||||||
|
// Filter on authorize=4 (AccessModeOwner) so a user-created team that
|
||||||
|
// happens to share the name "owners" is not accidentally affected.
|
||||||
|
_, err := x.Exec("UPDATE `team` SET visibility = ? WHERE lower_name = ? AND authorize = ?", 1, "owners", 4)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -370,6 +370,7 @@ func CreateOrganization(ctx context.Context, org *Organization, owner *user_mode
|
|||||||
NumMembers: 1,
|
NumMembers: 1,
|
||||||
IncludesAllRepositories: true,
|
IncludesAllRepositories: true,
|
||||||
CanCreateOrgRepo: true,
|
CanCreateOrgRepo: true,
|
||||||
|
Visibility: structs.VisibleTypeLimited,
|
||||||
}
|
}
|
||||||
if err = db.Insert(ctx, t); err != nil {
|
if err = db.Insert(ctx, t); err != nil {
|
||||||
return fmt.Errorf("insert owner team: %w", err)
|
return fmt.Errorf("insert owner team: %w", err)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
@@ -81,9 +82,36 @@ type Team struct {
|
|||||||
Members []*user_model.User `xorm:"-"`
|
Members []*user_model.User `xorm:"-"`
|
||||||
NumRepos int
|
NumRepos int
|
||||||
NumMembers int
|
NumMembers int
|
||||||
Units []*TeamUnit `xorm:"-"`
|
Units []*TeamUnit `xorm:"-"`
|
||||||
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
|
IncludesAllRepositories bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
|
CanCreateOrgRepo bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
|
Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
|
||||||
|
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
|
||||||
|
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
|
||||||
|
|
||||||
|
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
|
||||||
|
// the team's metadata, based on the team's visibility tier and the parent org's
|
||||||
|
// visibility. Privileged callers (site admins, org owners, team members) are
|
||||||
|
// decided by the caller before reaching here.
|
||||||
|
func (t *Team) CanNonMemberReadMeta(ctx context.Context, org, doer *user_model.User) (bool, error) {
|
||||||
|
switch t.Visibility {
|
||||||
|
case structs.VisibleTypePublic:
|
||||||
|
return HasOrgOrUserVisible(ctx, org, doer), nil
|
||||||
|
case structs.VisibleTypeLimited:
|
||||||
|
return IsOrganizationMember(ctx, t.OrgID, doer.ID)
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeTeamVisibility(s string) structs.VisibleType {
|
||||||
|
if vt, ok := structs.VisibilityModes[s]; ok {
|
||||||
|
return vt
|
||||||
|
}
|
||||||
|
return structs.VisibleTypePrivate
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/structs"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
@@ -50,9 +52,15 @@ type SearchTeamOptions struct {
|
|||||||
Keyword string
|
Keyword string
|
||||||
OrgID int64
|
OrgID int64
|
||||||
IncludeDesc bool
|
IncludeDesc bool
|
||||||
|
// IncludeVisibilities, when combined with UserID, also returns teams whose
|
||||||
|
// visibility is in this list, even if UserID is not a member. Typical values:
|
||||||
|
// - {limited,public} for org members
|
||||||
|
// - {public} for signed-in users who are not org members
|
||||||
|
// Leave empty to return only teams the user is a member of.
|
||||||
|
IncludeVisibilities []structs.VisibleType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (opts *SearchTeamOptions) toCond() builder.Cond {
|
func (opts *SearchTeamOptions) applyToSession(sess db.SQLSession) {
|
||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
|
|
||||||
if len(opts.Keyword) > 0 {
|
if len(opts.Keyword) > 0 {
|
||||||
@@ -68,11 +76,51 @@ func (opts *SearchTeamOptions) toCond() builder.Cond {
|
|||||||
cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
|
cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID})
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.UserID > 0 {
|
switch {
|
||||||
|
case opts.UserID > 0 && len(opts.IncludeVisibilities) > 0:
|
||||||
|
sess = sess.Join("LEFT", "team_user", "team_user.team_id = team.id AND team_user.uid = ?", opts.UserID)
|
||||||
|
cond = cond.And(builder.Or(
|
||||||
|
builder.Eq{"team_user.uid": opts.UserID},
|
||||||
|
builder.In("`team`.visibility", opts.IncludeVisibilities),
|
||||||
|
))
|
||||||
|
case opts.UserID > 0:
|
||||||
|
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
|
||||||
cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
|
cond = cond.And(builder.Eq{"team_user.uid": opts.UserID})
|
||||||
|
case len(opts.IncludeVisibilities) > 0:
|
||||||
|
cond = cond.And(builder.In("`team`.visibility", opts.IncludeVisibilities))
|
||||||
}
|
}
|
||||||
|
sess.Where(cond)
|
||||||
|
}
|
||||||
|
|
||||||
return cond
|
func VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn bool) []structs.VisibleType {
|
||||||
|
switch {
|
||||||
|
case isOrgMember:
|
||||||
|
return []structs.VisibleType{structs.VisibleTypeLimited, structs.VisibleTypePublic}
|
||||||
|
case isSignedIn:
|
||||||
|
return []structs.VisibleType{structs.VisibleTypePublic}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyTeamListFilter(ctx context.Context, orgID int64, viewer *user_model.User, isSignedIn bool, opts *SearchTeamOptions) error {
|
||||||
|
if viewer.IsAdmin {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
isOwner, err := IsOrganizationOwner(ctx, orgID, viewer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isOwner {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
isOrgMember, err := IsOrganizationMember(ctx, orgID, viewer.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.UserID = viewer.ID
|
||||||
|
opts.IncludeVisibilities = VisibleTeamVisibilitiesFor(isOrgMember, isSignedIn)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTeam search for teams. Caller is responsible to check permissions.
|
// SearchTeam search for teams. Caller is responsible to check permissions.
|
||||||
@@ -80,15 +128,12 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64,
|
|||||||
sess := db.GetEngine(ctx)
|
sess := db.GetEngine(ctx)
|
||||||
|
|
||||||
opts.SetDefaultValues()
|
opts.SetDefaultValues()
|
||||||
cond := opts.toCond()
|
opts.applyToSession(sess)
|
||||||
|
|
||||||
if opts.UserID > 0 {
|
|
||||||
sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id")
|
|
||||||
}
|
|
||||||
db.SetSessionPagination(sess, opts)
|
db.SetSessionPagination(sess, opts)
|
||||||
|
|
||||||
teams := make([]*Team, 0, opts.PageSize)
|
teams := make([]*Team, 0, opts.PageSize)
|
||||||
count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
|
count, err := sess.OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/structs"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -38,6 +40,43 @@ func TestTeam_IsMember(t *testing.T) {
|
|||||||
assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID))
|
assert.False(t, team.IsMember(t.Context(), unittest.NonexistentID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTeam_CanNonMemberReadMeta(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // public org
|
||||||
|
org35 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35}) // private org
|
||||||
|
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // member of org 3 and org 35
|
||||||
|
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // member of neither org
|
||||||
|
|
||||||
|
test := func(name string, team *organization.Team, org, doer *user_model.User, expected bool) {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
ok, err := team.CanNonMemberReadMeta(t.Context(), org, doer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public team is gated only by the parent org's visibility.
|
||||||
|
publicTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePublic}
|
||||||
|
test("public team, public org, member", publicTeam, org3, member, true)
|
||||||
|
test("public team, public org, outsider", publicTeam, org3, outsider, true)
|
||||||
|
|
||||||
|
// Public team inside a private org: only org members may see it.
|
||||||
|
publicTeamPrivOrg := &organization.Team{OrgID: 35, Visibility: structs.VisibleTypePublic}
|
||||||
|
test("public team, private org, org member", publicTeamPrivOrg, org35, member, true)
|
||||||
|
test("public team, private org, outsider", publicTeamPrivOrg, org35, outsider, false)
|
||||||
|
|
||||||
|
// Limited team: any org member, but never outsiders.
|
||||||
|
limitedTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypeLimited}
|
||||||
|
test("limited team, org member", limitedTeam, org3, member, true)
|
||||||
|
test("limited team, outsider", limitedTeam, org3, outsider, false)
|
||||||
|
|
||||||
|
// Private team is never visible to non-members; members/owners are admitted by the caller.
|
||||||
|
privateTeam := &organization.Team{OrgID: 3, Visibility: structs.VisibleTypePrivate}
|
||||||
|
test("private team, org member", privateTeam, org3, member, false)
|
||||||
|
test("private team, outsider", privateTeam, org3, outsider, false)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTeam_GetRepositories(t *testing.T) {
|
func TestTeam_GetRepositories(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
@@ -172,6 +211,52 @@ func TestGetUserOrgTeams(t *testing.T) {
|
|||||||
test(3, unittest.NonexistentID)
|
test(3, unittest.NonexistentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchTeamIncludeVisible(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
const orgID int64 = 3
|
||||||
|
// User 5 is an org member but only belongs to team 1 (Owners) — make sure
|
||||||
|
// they don't see team 2 (default private) but do see a freshly added
|
||||||
|
// limited team they are not a member of.
|
||||||
|
visible := &organization.Team{
|
||||||
|
OrgID: orgID,
|
||||||
|
LowerName: "visible-team",
|
||||||
|
Name: "visible-team",
|
||||||
|
AccessMode: 1, // read
|
||||||
|
Visibility: structs.VisibleTypeLimited,
|
||||||
|
}
|
||||||
|
assert.NoError(t, db.Insert(t.Context(), visible))
|
||||||
|
teams, _, err := organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
|
||||||
|
OrgID: orgID,
|
||||||
|
UserID: 2,
|
||||||
|
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
ids := make(map[int64]bool, len(teams))
|
||||||
|
for _, team := range teams {
|
||||||
|
assert.Equal(t, orgID, team.OrgID)
|
||||||
|
ids[team.ID] = true
|
||||||
|
}
|
||||||
|
// user 2 is in team 1 and team 2 in org 3, plus should see the new visible team.
|
||||||
|
assert.True(t, ids[1], "expected to see team 1 (member)")
|
||||||
|
assert.True(t, ids[2], "expected to see team 2 (member)")
|
||||||
|
assert.True(t, ids[visible.ID], "expected to see visible team")
|
||||||
|
|
||||||
|
// user 5 is only an org member in team 1, must not see secret team 2 but must see the visible one.
|
||||||
|
teams, _, err = organization.SearchTeam(t.Context(), &organization.SearchTeamOptions{
|
||||||
|
OrgID: orgID,
|
||||||
|
UserID: 5,
|
||||||
|
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(true, true),
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
ids = make(map[int64]bool, len(teams))
|
||||||
|
for _, team := range teams {
|
||||||
|
ids[team.ID] = true
|
||||||
|
}
|
||||||
|
assert.False(t, ids[2], "user 5 must not see private team 2")
|
||||||
|
assert.True(t, ids[visible.ID], "user 5 must see the limited team")
|
||||||
|
}
|
||||||
|
|
||||||
func TestHasTeamRepo(t *testing.T) {
|
func TestHasTeamRepo(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
@@ -783,7 +783,8 @@ func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (Repositor
|
|||||||
|
|
||||||
sess = sess.Where(cond).OrderBy(opts.OrderBy.String())
|
sess = sess.Where(cond).OrderBy(opts.OrderBy.String())
|
||||||
repos := make(RepositoryList, 0, opts.PageSize)
|
repos := make(RepositoryList, 0, opts.PageSize)
|
||||||
return repos, count, db.SetSessionPagination(sess, &opts).Find(&repos)
|
db.SetSessionPagination(sess, &opts)
|
||||||
|
return repos, count, sess.Find(&repos)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {
|
func GetOwnerRepositoriesByIDs(ctx context.Context, ownerID int64, repoIDs []int64) (RepositoryList, error) {
|
||||||
|
|||||||
@@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
package structs
|
package structs
|
||||||
|
|
||||||
|
// TeamVisibility controls who can list a team within its organization.
|
||||||
|
// - "public": visible to any signed-in user (still bounded by org visibility)
|
||||||
|
// - "limited": visible to any member of the parent organization
|
||||||
|
// - "private": visible only to team members and org owners
|
||||||
|
//
|
||||||
|
// swagger:enum TeamVisibility
|
||||||
|
type TeamVisibility string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TeamVisibilityPublic TeamVisibility = "public"
|
||||||
|
TeamVisibilityLimited TeamVisibility = "limited"
|
||||||
|
TeamVisibilityPrivate TeamVisibility = "private"
|
||||||
|
)
|
||||||
|
|
||||||
// Team represents a team in an organization
|
// Team represents a team in an organization
|
||||||
type Team struct {
|
type Team struct {
|
||||||
// The unique identifier of the team
|
// The unique identifier of the team
|
||||||
@@ -24,6 +38,11 @@ type Team struct {
|
|||||||
UnitsMap map[string]string `json:"units_map"`
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
// Whether the team can create repositories in the organization
|
// Whether the team can create repositories in the organization
|
||||||
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
||||||
|
// Team visibility within the organization. "private" teams are only
|
||||||
|
// listable by members and org owners; "limited" teams are listable by
|
||||||
|
// any organization member; "public" teams are listable by any signed-in
|
||||||
|
// user.
|
||||||
|
Visibility TeamVisibility `json:"visibility"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTeamOption options for creating a team
|
// CreateTeamOption options for creating a team
|
||||||
@@ -42,6 +61,8 @@ type CreateTeamOption struct {
|
|||||||
UnitsMap map[string]string `json:"units_map"`
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
// Whether the team can create repositories in the organization
|
// Whether the team can create repositories in the organization
|
||||||
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
CanCreateOrgRepo bool `json:"can_create_org_repo"`
|
||||||
|
// Team visibility within the organization. Defaults to "private".
|
||||||
|
Visibility TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditTeamOption options for editing a team
|
// EditTeamOption options for editing a team
|
||||||
@@ -60,4 +81,7 @@ type EditTeamOption struct {
|
|||||||
UnitsMap map[string]string `json:"units_map"`
|
UnitsMap map[string]string `json:"units_map"`
|
||||||
// Whether the team can create repositories in the organization
|
// Whether the team can create repositories in the organization
|
||||||
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
|
CanCreateOrgRepo *bool `json:"can_create_org_repo"`
|
||||||
|
// Team visibility within the organization. When omitted, visibility is
|
||||||
|
// left unchanged.
|
||||||
|
Visibility *TeamVisibility `json:"visibility" binding:"OmitEmpty;In(public,limited,private)"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2865,6 +2865,14 @@
|
|||||||
"org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.",
|
"org.teams.all_repositories_read_permission_desc": "This team grants <strong>Read</strong> access to <strong>all repositories</strong>: members can view and clone repositories.",
|
||||||
"org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.",
|
"org.teams.all_repositories_write_permission_desc": "This team grants <strong>Write</strong> access to <strong>all repositories</strong>: members can read from and push to repositories.",
|
||||||
"org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.",
|
"org.teams.all_repositories_admin_permission_desc": "This team grants <strong>Admin</strong> access to <strong>all repositories</strong>: members can read from, push to and add collaborators to repositories.",
|
||||||
|
"org.teams.visibility": "Visibility",
|
||||||
|
"org.teams.visibility_private": "Private",
|
||||||
|
"org.teams.visibility_private_helper": "Visible only to team members and organization owners.",
|
||||||
|
"org.teams.visibility_limited": "Limited",
|
||||||
|
"org.teams.visibility_limited_helper": "Visible to all members of this organization.",
|
||||||
|
"org.teams.visibility_public": "Public",
|
||||||
|
"org.teams.visibility_public_helper": "Visible to any signed-in user.",
|
||||||
|
"org.teams.owners_visibility_fixed": "The Owners team visibility cannot be changed.",
|
||||||
"org.teams.invite.title": "You have been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.",
|
"org.teams.invite.title": "You have been invited to join team <strong>%s</strong> in organization <strong>%s</strong>.",
|
||||||
"org.teams.invite.by": "Invited by %s",
|
"org.teams.invite.by": "Invited by %s",
|
||||||
"org.teams.invite.description": "Please click the button below to join the team.",
|
"org.teams.invite.description": "Please click the button below to join the team.",
|
||||||
|
|||||||
@@ -505,41 +505,79 @@ func reqOrgOwnership() func(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqTeamMembership user should be an team member, or a site admin
|
func teamAccessPrivileged(ctx *context.APIContext) (orgID int64, privileged, ok bool) {
|
||||||
func reqTeamMembership() func(ctx *context.APIContext) {
|
if ctx.IsUserSiteAdmin() {
|
||||||
|
return 0, true, true
|
||||||
|
}
|
||||||
|
if ctx.Org.Team == nil {
|
||||||
|
setting.PanicInDevOrTesting("teamAccess: unprepared context")
|
||||||
|
ctx.APIErrorInternal(errors.New("teamAccess: unprepared context"))
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID = ctx.Org.Team.OrgID
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
} else if isOwner {
|
||||||
|
return orgID, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return 0, false, false
|
||||||
|
}
|
||||||
|
return orgID, isTeamMember, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func denyNonTeamMember(ctx *context.APIContext, orgID int64) {
|
||||||
|
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
} else if isOrgMember {
|
||||||
|
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reqTeamReadAccess allows callers who can list the team to read its metadata.
|
||||||
|
// Non-members are admitted by the team's visibility tier and parent org visibility.
|
||||||
|
// Not sufficient for mutations — use reqOrgOwnership() or reqTeamMembership() for those.
|
||||||
|
func reqTeamReadAccess() func(ctx *context.APIContext) {
|
||||||
return func(ctx *context.APIContext) {
|
return func(ctx *context.APIContext) {
|
||||||
if ctx.IsUserSiteAdmin() {
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
|
if !ok || privileged {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ctx.Org.Team == nil {
|
if ctx.Org.Organization == nil {
|
||||||
setting.PanicInDevOrTesting("reqTeamMembership: unprepared context")
|
setting.PanicInDevOrTesting("reqTeamReadAccess: organization not loaded")
|
||||||
ctx.APIErrorInternal(errors.New("reqTeamMembership: unprepared context"))
|
ctx.APIErrorInternal(errors.New("reqTeamReadAccess: organization not loaded"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
orgID := ctx.Org.Team.OrgID
|
visible, err := ctx.Org.Team.CanNonMemberReadMeta(ctx, ctx.Org.Organization.AsUser(), ctx.Doer)
|
||||||
isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
} else if isOwner {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if !visible {
|
||||||
|
// Not admitted by visibility: 403 for org members, 404 otherwise.
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil {
|
// reqTeamMembership user should be a team member, or a site admin
|
||||||
ctx.APIErrorInternal(err)
|
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||||
return
|
return func(ctx *context.APIContext) {
|
||||||
} else if !isTeamMember {
|
orgID, privileged, ok := teamAccessPrivileged(ctx)
|
||||||
isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID)
|
if !ok || privileged {
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
} else if isOrgMember {
|
|
||||||
ctx.APIError(http.StatusForbidden, "Must be a team member")
|
|
||||||
} else {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
denyNonTeamMember(ctx, orgID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,6 +687,17 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ctx.Org.Organization == nil {
|
||||||
|
ctx.Org.Organization, err = organization.GetOrgByID(ctx, ctx.Org.Team.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
if organization.IsErrOrgNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1703,25 +1752,31 @@ func Routes() *web.Router {
|
|||||||
}, reqToken(), reqOrgOwnership())
|
}, reqToken(), reqOrgOwnership())
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly())
|
||||||
m.Group("/teams/{teamid}", func() {
|
m.Group("/teams/{teamid}", func() {
|
||||||
m.Combo("").Get(reqToken(), org.GetTeam).
|
m.Combo("").Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
|
||||||
Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam).
|
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
|
Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam)
|
||||||
|
m.Group("", func() {
|
||||||
|
m.Get("", org.GetTeam)
|
||||||
|
m.Group("/members", func() {
|
||||||
|
m.Get("", reqOrgMembership(), org.GetTeamMembers)
|
||||||
|
m.Combo("/{username}").Get(reqOrgMembership(), org.GetTeamMember)
|
||||||
|
})
|
||||||
|
m.Group("/repos", func() {
|
||||||
|
m.Get("", org.GetTeamRepos)
|
||||||
|
m.Combo("/{org}/{reponame}").Get(org.GetTeamRepo)
|
||||||
|
})
|
||||||
|
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
||||||
|
}, reqTeamReadAccess())
|
||||||
m.Group("/members", func() {
|
m.Group("/members", func() {
|
||||||
m.Get("", reqToken(), org.GetTeamMembers)
|
|
||||||
m.Combo("/{username}").
|
m.Combo("/{username}").
|
||||||
Get(reqToken(), org.GetTeamMember).
|
|
||||||
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
|
Put(reqToken(), reqOrgOwnership(), org.AddTeamMember).
|
||||||
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
|
Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember)
|
||||||
})
|
})
|
||||||
m.Group("/repos", func() {
|
m.Group("/repos", func() {
|
||||||
m.Get("", reqToken(), org.GetTeamRepos)
|
|
||||||
m.Combo("/{org}/{reponame}").
|
m.Combo("/{org}/{reponame}").
|
||||||
Put(reqToken(), org.AddTeamRepository).
|
Put(reqToken(), reqTeamMembership(), org.AddTeamRepository).
|
||||||
Delete(reqToken(), org.RemoveTeamRepository).
|
Delete(reqToken(), reqTeamMembership(), org.RemoveTeamRepository)
|
||||||
Get(reqToken(), org.GetTeamRepo)
|
|
||||||
})
|
})
|
||||||
m.Get("/activities/feeds", org.ListTeamActivityFeeds)
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), checkTokenPublicOnly())
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly())
|
|
||||||
|
|
||||||
m.Group("/admin", func() {
|
m.Group("/admin", func() {
|
||||||
m.Group("/cron", func() {
|
m.Group("/cron", func() {
|
||||||
|
|||||||
@@ -55,10 +55,15 @@ func ListTeams(ctx *context.APIContext) {
|
|||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
listOptions := utils.GetListOptions(ctx)
|
listOptions := utils.GetListOptions(ctx)
|
||||||
teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
opts := &organization.SearchTeamOptions{
|
||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
OrgID: ctx.Org.Organization.ID,
|
OrgID: ctx.Org.Organization.ID,
|
||||||
})
|
}
|
||||||
|
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
teams, count, err := organization.SearchTeam(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
@@ -218,6 +223,7 @@ func CreateTeam(ctx *context.APIContext) {
|
|||||||
IncludesAllRepositories: form.IncludesAllRepositories,
|
IncludesAllRepositories: form.IncludesAllRepositories,
|
||||||
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
||||||
AccessMode: teamPermission,
|
AccessMode: teamPermission,
|
||||||
|
Visibility: organization.NormalizeTeamVisibility(string(form.Visibility)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if team.AccessMode < perm.AccessModeAdmin {
|
if team.AccessMode < perm.AccessModeAdmin {
|
||||||
@@ -295,6 +301,10 @@ func EditTeam(ctx *context.APIContext) {
|
|||||||
team.Description = *form.Description
|
team.Description = *form.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.Visibility != nil && !team.IsOwnerTeam() {
|
||||||
|
team.Visibility = organization.NormalizeTeamVisibility(string(*form.Visibility))
|
||||||
|
}
|
||||||
|
|
||||||
isAuthChanged := false
|
isAuthChanged := false
|
||||||
isIncludeAllChanged := false
|
isIncludeAllChanged := false
|
||||||
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
|
if !team.IsOwnerTeam() && len(form.Permission) != 0 {
|
||||||
@@ -387,15 +397,6 @@ func GetTeamMembers(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.APIErrorInternal(err)
|
|
||||||
return
|
|
||||||
} else if !isMember && !ctx.Doer.IsAdmin {
|
|
||||||
ctx.APIErrorNotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
listOptions := utils.GetListOptions(ctx)
|
listOptions := utils.GetListOptions(ctx)
|
||||||
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{
|
||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
@@ -574,14 +575,20 @@ func GetTeamRepos(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repos := make([]*api.Repository, len(teamRepos))
|
repos := make([]*api.Repository, 0, len(teamRepos))
|
||||||
for i, repo := range teamRepos {
|
for _, repo := range teamRepos {
|
||||||
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
|
permission, err := access_model.GetDoerRepoPermission(ctx, repo, ctx.Doer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repos[i] = convert.ToRepo(ctx, repo, permission)
|
// A team's repo list is reachable by non-team-members through the team's
|
||||||
|
// visibility tier, so never expose repos (incl. their names) the doer
|
||||||
|
// cannot access.
|
||||||
|
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repos = append(repos, convert.ToRepo(ctx, repo, permission))
|
||||||
}
|
}
|
||||||
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
|
ctx.SetLinkHeader(int64(team.NumRepos), listOptions.PageSize)
|
||||||
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
ctx.SetTotalCountHeader(int64(team.NumRepos))
|
||||||
@@ -633,6 +640,12 @@ func GetTeamRepo(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The team may be reachable by a non-team-member via its visibility tier;
|
||||||
|
// don't confirm the existence of a repo the doer cannot access.
|
||||||
|
if !permission.HasAnyUnitAccessOrPublicAccess() {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
|
ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission))
|
||||||
}
|
}
|
||||||
@@ -806,9 +819,9 @@ func SearchTeam(ctx *context.APIContext) {
|
|||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only admin is allowed to search for all teams
|
if err := organization.ApplyTeamListFilter(ctx, ctx.Org.Organization.ID, ctx.Doer, ctx.IsSigned, opts); err != nil {
|
||||||
if !ctx.Doer.IsAdmin {
|
ctx.APIErrorInternal(err)
|
||||||
opts.UserID = ctx.Doer.ID
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
teams, maxResults, err := organization.SearchTeam(ctx, opts)
|
teams, maxResults, err := organization.SearchTeam(ctx, opts)
|
||||||
|
|||||||
@@ -101,7 +101,26 @@ func home(ctx *context.Context, viewRepositories bool) {
|
|||||||
|
|
||||||
const orgOverviewTeamsLimit = 5
|
const orgOverviewTeamsLimit = 5
|
||||||
ctx.Data["OrgOverviewMembers"] = members
|
ctx.Data["OrgOverviewMembers"] = members
|
||||||
ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)]
|
// The overview widget shows only teams the viewer belongs to. ctx.Org.Teams
|
||||||
|
// may include visible-but-not-joined teams (via IncludeVisibilities for
|
||||||
|
// signed-in non-members), so re-query the viewer's own membership; owners
|
||||||
|
// keep the full list they are entitled to manage.
|
||||||
|
overviewTeams := ctx.Org.Teams
|
||||||
|
if !ctx.Org.IsOwner {
|
||||||
|
overviewTeams = nil
|
||||||
|
if ctx.Org.IsMember {
|
||||||
|
overviewTeams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||||
|
OrgID: org.ID,
|
||||||
|
UserID: ctx.Doer.ID,
|
||||||
|
ListOptions: db.ListOptions{Page: 1, PageSize: orgOverviewTeamsLimit},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("SearchTeam", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Data["OrgOverviewTeams"] = overviewTeams[:min(len(overviewTeams), orgOverviewTeamsLimit)]
|
||||||
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
|
ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull
|
||||||
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
|
ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
@@ -80,6 +81,8 @@ func Teams(ctx *context.Context) {
|
|||||||
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
|
UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID),
|
||||||
Keyword: keyword,
|
Keyword: keyword,
|
||||||
IncludeDesc: true,
|
IncludeDesc: true,
|
||||||
|
IncludeVisibilities: util.Iif(shouldSeeAllOrgTeams, nil,
|
||||||
|
org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)),
|
||||||
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
|
ListOptions: db.ListOptions{Page: page, PageSize: pagingNum},
|
||||||
}
|
}
|
||||||
return org_model.SearchTeam(ctx, opts)
|
return org_model.SearchTeam(ctx, opts)
|
||||||
@@ -377,6 +380,7 @@ func NewTeamPost(ctx *context.Context) {
|
|||||||
AccessMode: teamPermission,
|
AccessMode: teamPermission,
|
||||||
IncludesAllRepositories: includesAllRepositories,
|
IncludesAllRepositories: includesAllRepositories,
|
||||||
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
CanCreateOrgRepo: form.CanCreateOrgRepo,
|
||||||
|
Visibility: org_model.NormalizeTeamVisibility(form.Visibility),
|
||||||
}
|
}
|
||||||
|
|
||||||
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
|
units := make([]*org_model.TeamUnit, 0, len(unitPerms))
|
||||||
@@ -477,13 +481,22 @@ func SearchTeam(ctx *context.Context) {
|
|||||||
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldSeeAll, err := context.UserShouldSeeAllOrgTeams(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
opts := &org_model.SearchTeamOptions{
|
opts := &org_model.SearchTeamOptions{
|
||||||
// UserID is not set because the router already requires the doer to be an org admin. Thus, we don't need to restrict to teams that the user belongs in
|
|
||||||
Keyword: ctx.FormTrim("q"),
|
Keyword: ctx.FormTrim("q"),
|
||||||
OrgID: ctx.Org.Organization.ID,
|
OrgID: ctx.Org.Organization.ID,
|
||||||
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
|
IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"),
|
||||||
ListOptions: listOptions,
|
ListOptions: listOptions,
|
||||||
}
|
}
|
||||||
|
if !shouldSeeAll {
|
||||||
|
opts.UserID = ctx.Doer.ID
|
||||||
|
opts.IncludeVisibilities = org_model.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, ctx.IsSigned)
|
||||||
|
}
|
||||||
|
|
||||||
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
|
teams, maxResults, err := org_model.SearchTeam(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -556,8 +569,11 @@ func EditTeamPost(ctx *context.Context) {
|
|||||||
t.IncludesAllRepositories = includesAllRepositories
|
t.IncludesAllRepositories = includesAllRepositories
|
||||||
}
|
}
|
||||||
t.CanCreateOrgRepo = form.CanCreateOrgRepo
|
t.CanCreateOrgRepo = form.CanCreateOrgRepo
|
||||||
|
t.Visibility = org_model.NormalizeTeamVisibility(form.Visibility)
|
||||||
} else {
|
} else {
|
||||||
t.CanCreateOrgRepo = true
|
t.CanCreateOrgRepo = true
|
||||||
|
// The owner team must remain listable to all org members.
|
||||||
|
t.Visibility = structs.VisibleTypeLimited
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Description = form.Description
|
t.Description = form.Description
|
||||||
|
|||||||
@@ -179,20 +179,28 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
|
|||||||
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
|
ctx.ServerError("UserShouldSeeAllOrgTeams", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ctx.Org.IsMember {
|
switch {
|
||||||
if shouldSeeAllTeams {
|
case shouldSeeAllTeams:
|
||||||
ctx.Org.Teams, err = org.LoadTeams(ctx)
|
ctx.Org.Teams, err = org.LoadTeams(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("LoadTeams", err)
|
ctx.ServerError("LoadTeams", err)
|
||||||
return
|
return
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.Org.Teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetUserTeams", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
case ctx.IsSigned:
|
||||||
|
// Signed-in non-members still see teams whose visibility tier
|
||||||
|
// includes them (public for any signed-in user, plus limited
|
||||||
|
// for org members), and any team they directly belong to.
|
||||||
|
ctx.Org.Teams, _, err = organization.SearchTeam(ctx, &organization.SearchTeamOptions{
|
||||||
|
OrgID: org.ID,
|
||||||
|
UserID: ctx.Doer.ID,
|
||||||
|
IncludeVisibilities: organization.VisibleTeamVisibilitiesFor(ctx.Org.IsMember, true),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("SearchTeam", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Org.IsMember {
|
||||||
ctx.Data["NumTeams"] = len(ctx.Org.Teams)
|
ctx.Data["NumTeams"] = len(ctx.Org.Teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +211,6 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
|
|||||||
if strings.EqualFold(team.LowerName, teamName) {
|
if strings.EqualFold(team.LowerName, teamName) {
|
||||||
teamExists = true
|
teamExists = true
|
||||||
ctx.Org.Team = team
|
ctx.Org.Team = team
|
||||||
ctx.Org.IsTeamMember = true
|
|
||||||
ctx.Data["Team"] = ctx.Org.Team
|
ctx.Data["Team"] = ctx.Org.Team
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -214,13 +221,24 @@ func OrgAssignment(orgAssignmentOpts OrgAssignmentOptions) func(ctx *Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Membership in a visible team is not implied by its presence in
|
||||||
|
// ctx.Org.Teams; admins/org owners keep the privileged flag set
|
||||||
|
// earlier in this function.
|
||||||
|
if !ctx.Org.IsOwner {
|
||||||
|
ctx.Org.IsTeamMember, err = organization.IsTeamMember(ctx, org.ID, ctx.Org.Team.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("IsTeamMember", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
|
ctx.Data["IsTeamMember"] = ctx.Org.IsTeamMember
|
||||||
if opts.RequireTeamMember && !ctx.Org.IsTeamMember {
|
if opts.RequireTeamMember && !ctx.Org.IsTeamMember {
|
||||||
ctx.NotFound(err)
|
ctx.NotFound(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Org.IsTeamAdmin = ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess()
|
isTeamOwnerOrAdmin := ctx.Org.Team.IsOwnerTeam() || ctx.Org.Team.HasAdminAccess()
|
||||||
|
ctx.Org.IsTeamAdmin = ctx.Org.IsOwner || (ctx.Org.IsTeamMember && isTeamOwnerOrAdmin)
|
||||||
ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
|
ctx.Data["IsTeamAdmin"] = ctx.Org.IsTeamAdmin
|
||||||
if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin {
|
if opts.RequireTeamAdmin && !ctx.Org.IsTeamAdmin {
|
||||||
ctx.NotFound(err)
|
ctx.NotFound(err)
|
||||||
|
|||||||
@@ -836,6 +836,7 @@ func ToTeams(ctx context.Context, teams []*organization.Team, loadOrgs bool) ([]
|
|||||||
Permission: api.AccessLevelName(t.AccessMode.ToString()),
|
Permission: api.AccessLevelName(t.AccessMode.ToString()),
|
||||||
Units: t.GetUnitNames(),
|
Units: t.GetUnitNames(),
|
||||||
UnitsMap: t.GetUnitsMap(),
|
UnitsMap: t.GetUnitsMap(),
|
||||||
|
Visibility: api.TeamVisibility(t.Visibility.String()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if loadOrgs {
|
if loadOrgs {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type CreateTeamForm struct {
|
|||||||
Permission string
|
Permission string
|
||||||
RepoAccess string
|
RepoAccess string
|
||||||
CanCreateOrgRepo bool
|
CanCreateOrgRepo bool
|
||||||
|
Visibility string `binding:"OmitEmpty;In(public,limited,private)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the fields
|
// Validate validates the fields
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
|
|||||||
|
|
||||||
sess := db.GetEngine(ctx)
|
sess := db.GetEngine(ctx)
|
||||||
if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description",
|
if _, err = sess.ID(t.ID).Cols("name", "lower_name", "description",
|
||||||
"can_create_org_repo", "authorize", "includes_all_repositories").Update(t); err != nil {
|
"can_create_org_repo", "authorize", "includes_all_repositories", "visibility").Update(t); err != nil {
|
||||||
return fmt.Errorf("update: %w", err)
|
return fmt.Errorf("update: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
<div class="required field {{if .Err_TeamName}}error{{end}}">
|
<div class="required field {{if .Err_TeamName}}error{{end}}">
|
||||||
<label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label>
|
<label for="team_name">{{ctx.Locale.Tr "org.team_name"}}</label>
|
||||||
{{if eq .Team.LowerName "owners"}}
|
{{if .Team.IsOwnerTeam}}
|
||||||
<input type="hidden" name="team_name" value="{{.Team.Name}}">
|
<input type="hidden" name="team_name" value="{{.Team.Name}}">
|
||||||
{{end}}
|
{{end}}
|
||||||
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if eq .Team.LowerName "owners"}}disabled{{end}} autofocus>
|
<input id="team_name" name="team_name" value="{{.Team.Name}}" required {{if .Team.IsOwnerTeam}}disabled{{end}} autofocus>
|
||||||
<span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span>
|
<span class="help">{{ctx.Locale.Tr "org.team_name_helper"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field {{if .Err_Description}}error{{end}}">
|
<div class="field {{if .Err_Description}}error{{end}}">
|
||||||
@@ -23,7 +23,47 @@
|
|||||||
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
|
<input id="description" name="description" value="{{.Team.Description}}" maxlength="255">
|
||||||
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
|
<span class="help">{{ctx.Locale.Tr "org.team_desc_helper"}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{if not (eq .Team.LowerName "owners")}}
|
{{if .Team.IsOwnerTeam}}
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
|
||||||
|
<div class="tw-mb-1">
|
||||||
|
{{if .Team.IsPrivate}}
|
||||||
|
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||||
|
{{else if .Team.IsLimited}}
|
||||||
|
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||||
|
{{else if .Team.IsPublic}}
|
||||||
|
<span class="ui mini label">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="help">{{ctx.Locale.Tr "org.teams.owners_visibility_fixed"}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if not .Team.IsOwnerTeam}}
|
||||||
|
<div class="grouped field">
|
||||||
|
<label>{{ctx.Locale.Tr "org.teams.visibility"}}</label>
|
||||||
|
<br>
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui radio checkbox">
|
||||||
|
<input type="radio" name="visibility" value="private" {{if or .PageIsOrgTeamsNew .Team.IsPrivate}}checked{{end}}>
|
||||||
|
<label>{{ctx.Locale.Tr "org.teams.visibility_private"}}</label>
|
||||||
|
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui radio checkbox">
|
||||||
|
<input type="radio" name="visibility" value="limited" {{if .Team.IsLimited}}checked{{end}}>
|
||||||
|
<label>{{ctx.Locale.Tr "org.teams.visibility_limited"}}</label>
|
||||||
|
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui radio checkbox">
|
||||||
|
<input type="radio" name="visibility" value="public" {{if .Team.IsPublic}}checked{{end}}>
|
||||||
|
<label>{{ctx.Locale.Tr "org.teams.visibility_public"}}</label>
|
||||||
|
<span class="help">{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grouped field">
|
<div class="grouped field">
|
||||||
<label>{{ctx.Locale.Tr "org.team_access_desc"}}</label>
|
<label>{{ctx.Locale.Tr "org.team_access_desc"}}</label>
|
||||||
<br>
|
<br>
|
||||||
@@ -135,7 +175,7 @@
|
|||||||
<button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button>
|
<button class="ui primary button">{{ctx.Locale.Tr "org.create_team"}}</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
|
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
|
||||||
{{if not (eq .Team.LowerName "owners")}}
|
{{if not .Team.IsOwnerTeam}}
|
||||||
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
|
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<div class="ui six wide column">
|
<div class="ui six wide column">
|
||||||
<h4 class="ui top attached header flex-left-right">
|
<h4 class="ui top attached header flex-left-right">
|
||||||
<strong>{{.Team.Name}}</strong>
|
<div class="flex-text-inline">
|
||||||
|
<strong>{{.Team.Name}}</strong>
|
||||||
|
{{if .Team.IsPrivate}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||||
|
{{else if .Team.IsLimited}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||||
|
{{else if .Team.IsPublic}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
{{if .Team.IsMember ctx $.SignedUser.ID}}
|
||||||
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
<button class="ui red mini compact button show-modal" data-modal="#org-member-leave-team"
|
||||||
@@ -26,7 +35,7 @@
|
|||||||
|
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
{{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}}
|
{{/* TODO: old indent is kept to make diff changes minimal, can be reformatted in the future */}}
|
||||||
{{if eq .Team.LowerName "owners"}}
|
{{if .Team.IsOwnerTeam}}
|
||||||
<p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p>
|
<p>{{ctx.Locale.Tr "org.teams.owners_permission_desc"}}</p>
|
||||||
<p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p>
|
<p>{{ctx.Locale.Tr "org.teams.owners_permission_suggestion"}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -21,7 +21,16 @@
|
|||||||
{{range $team := $.OrgListTeams}}
|
{{range $team := $.OrgListTeams}}
|
||||||
<div class="column team-item-box">
|
<div class="column team-item-box">
|
||||||
<div class="ui top attached header muted-links flex-left-right team-item-header">
|
<div class="ui top attached header muted-links flex-left-right team-item-header">
|
||||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
|
<div class="flex-text-inline">
|
||||||
|
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}"><strong>{{.Name}}</strong></a>
|
||||||
|
{{if .IsPrivate}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_private_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_private"}}</span>
|
||||||
|
{{else if .IsLimited}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_limited_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_limited"}}</span>
|
||||||
|
{{else if .IsPublic}}
|
||||||
|
<span class="ui mini label" data-tooltip-content="{{ctx.Locale.Tr "org.teams.visibility_public_helper"}}">{{ctx.Locale.Tr "org.teams.visibility_public"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<div class="flex-text-block tw-flex-wrap">
|
<div class="flex-text-block tw-flex-wrap">
|
||||||
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
|
<a href="{{$.OrgLink}}/teams/{{.LowerName | PathEscape}}">{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}}</a>
|
||||||
·
|
·
|
||||||
|
|||||||
33
templates/swagger/v1_json.tmpl
generated
33
templates/swagger/v1_json.tmpl
generated
@@ -24992,6 +24992,17 @@
|
|||||||
},
|
},
|
||||||
"x-go-name": "UnitsMap",
|
"x-go-name": "UnitsMap",
|
||||||
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}"
|
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"public",
|
||||||
|
"limited",
|
||||||
|
"private"
|
||||||
|
],
|
||||||
|
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"x-go-name": "Visibility"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"x-go-package": "gitea.dev/modules/structs"
|
||||||
@@ -26190,6 +26201,17 @@
|
|||||||
"repo.releases": "none",
|
"repo.releases": "none",
|
||||||
"repo.wiki": "admin"
|
"repo.wiki": "admin"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"public",
|
||||||
|
"limited",
|
||||||
|
"private"
|
||||||
|
],
|
||||||
|
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"x-go-name": "Visibility"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"x-go-package": "gitea.dev/modules/structs"
|
||||||
@@ -30096,6 +30118,17 @@
|
|||||||
"repo.releases": "none",
|
"repo.releases": "none",
|
||||||
"repo.wiki": "admin"
|
"repo.wiki": "admin"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"public",
|
||||||
|
"limited",
|
||||||
|
"private"
|
||||||
|
],
|
||||||
|
"x-go-enum-desc": "public TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate",
|
||||||
|
"x-go-name": "Visibility"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"x-go-package": "gitea.dev/modules/structs"
|
||||||
|
|||||||
32
templates/swagger/v1_openapi3_json.tmpl
generated
32
templates/swagger/v1_openapi3_json.tmpl
generated
@@ -4805,6 +4805,14 @@
|
|||||||
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}",
|
"example": "{\"repo.actions\",\"repo.packages\",\"repo.code\":\"read\",\"repo.issues\":\"write\",\"repo.ext_issues\":\"none\",\"repo.wiki\":\"admin\",\"repo.pulls\":\"owner\",\"repo.releases\":\"none\",\"repo.projects\":\"none\",\"repo.ext_wiki\":\"none\"}",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-name": "UnitsMap"
|
"x-go-name": "UnitsMap"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TeamVisibility"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Team visibility within the organization. Defaults to \"private\".\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -5991,6 +5999,14 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-name": "UnitsMap"
|
"x-go-name": "UnitsMap"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TeamVisibility"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Team visibility within the organization. When omitted, visibility is\nleft unchanged.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -9938,11 +9954,27 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-name": "UnitsMap"
|
"x-go-name": "UnitsMap"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/TeamVisibility"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Team visibility within the organization. \"private\" teams are only\nlistable by members and org owners; \"limited\" teams are listable by\nany organization member; \"public\" teams are listable by any signed-in\nuser.\npublic TeamVisibilityPublic\nlimited TeamVisibilityLimited\nprivate TeamVisibilityPrivate"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"x-go-package": "gitea.dev/modules/structs"
|
"x-go-package": "gitea.dev/modules/structs"
|
||||||
},
|
},
|
||||||
|
"TeamVisibility": {
|
||||||
|
"enum": [
|
||||||
|
"public",
|
||||||
|
"limited",
|
||||||
|
"private"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"TimeStamp": {
|
"TimeStamp": {
|
||||||
"description": "TimeStamp defines a timestamp",
|
"description": "TimeStamp defines a timestamp",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
"gitea.dev/models/repo"
|
"gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/structs"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
"gitea.dev/tests"
|
"gitea.dev/tests"
|
||||||
@@ -303,3 +305,61 @@ func TestAPIGetTeamRepo(t *testing.T) {
|
|||||||
AddTokenAuth(token5)
|
AddTokenAuth(token5)
|
||||||
MakeRequest(t, req, http.StatusNotFound)
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPITeamVisibilityAccess(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
insertTestTeam := func(t *testing.T, orgID int64, name string, visibility structs.VisibleType) *organization.Team {
|
||||||
|
t.Helper()
|
||||||
|
team := &organization.Team{
|
||||||
|
OrgID: orgID,
|
||||||
|
LowerName: name,
|
||||||
|
Name: name,
|
||||||
|
AccessMode: perm.AccessModeRead,
|
||||||
|
Visibility: visibility,
|
||||||
|
}
|
||||||
|
assert.NoError(t, db.Insert(t.Context(), team))
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedTeam := insertTestTeam(t, 3, "limited-team", structs.VisibleTypeLimited)
|
||||||
|
|
||||||
|
// Org member who can read a limited team must not mutate its repos without membership.
|
||||||
|
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
token := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteOrganization)
|
||||||
|
req := NewRequestf(t, "PUT", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusForbidden)
|
||||||
|
|
||||||
|
publicTeam := insertTestTeam(t, 23, "public-team", structs.VisibleTypePublic)
|
||||||
|
|
||||||
|
// Public team in a private org must not be readable by outsiders.
|
||||||
|
outsider := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
|
token = getUserToken(t, outsider.Name, auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d", publicTeam.ID).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
|
// Member lookup must require org membership even for public teams.
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/members/%s", publicTeam.ID, outsider.Name).
|
||||||
|
AddTokenAuth(token)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
|
||||||
|
// A limited team's repo list must not leak repos the viewer cannot access.
|
||||||
|
// repo3 is private; user28 is an org3 member (team12, no repo access) who can
|
||||||
|
// read the limited team but has no access to repo3.
|
||||||
|
assert.NoError(t, db.Insert(t.Context(), &organization.TeamRepo{OrgID: 3, TeamID: limitedTeam.ID, RepoID: 3}))
|
||||||
|
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||||
|
token28 := getUserToken(t, user28.Name, auth_model.AccessTokenScopeReadOrganization)
|
||||||
|
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos", limitedTeam.ID).AddTokenAuth(token28)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var repos []*api.Repository
|
||||||
|
DecodeJSON(t, resp, &repos)
|
||||||
|
for _, r := range repos {
|
||||||
|
assert.NotEqual(t, int64(3), r.ID, "must not leak inaccessible private repo3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single-repo lookup must not confirm an inaccessible repo's existence.
|
||||||
|
req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/org3/repo3", limitedTeam.ID).AddTokenAuth(token28)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user