Skip to content

Commit bb885d6

Browse files
committed
Allow disabling authentication related user features
We have some instances that only allow using an external authentication source for authentication. In this case, users changing their email, password, or linked OpenID connections will not have any effect, and we'd like to prevent showing that to them to prevent confusion. Included in this are several changes to support this: * A new setting to disable user managed authentication credentials (email, password & OpenID connections) * A new setting to disable user managed MFA (2FA codes & WebAuthn) * Fix an issue where some templates had separate logic for determining if a feature was disabled since it didn't check the globally disabled features * Hide more user setting pages in the navbar when their settings aren't enabled
1 parent 3bd87fb commit bb885d6

File tree

19 files changed

+168
-17
lines changed

19 files changed

+168
-17
lines changed

custom/conf/app.example.ini

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,15 +1488,19 @@ LEVEL = Info
14881488
;;
14891489
;; Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
14901490
;DEFAULT_EMAIL_NOTIFICATIONS = enabled
1491-
;; Disabled features for users, could be "deletion", "manage_ssh_keys","manage_gpg_keys" more features can be disabled in future
1491+
;; Disabled features for users could be "deletion", "manage_ssh_keys", "manage_gpg_keys", "manage_mfa", "manage_credentials" more features can be disabled in future
14921492
;; - deletion: a user cannot delete their own account
14931493
;; - manage_ssh_keys: a user cannot configure ssh keys
14941494
;; - manage_gpg_keys: a user cannot configure gpg keys
1495+
;; - manage_mfa: a user cannot configure mfa devices
1496+
;; - manage_credentials: a user cannot configure emails, passwords, or openid
14951497
;USER_DISABLED_FEATURES =
1496-
;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior.
1498+
;; Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be "deletion", "manage_ssh_keys", "manage_gpg_keys", "manage_mfa", "manage_credentials". This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior.
14971499
;; - deletion: a user cannot delete their own account
14981500
;; - manage_ssh_keys: a user cannot configure ssh keys
14991501
;; - manage_gpg_keys: a user cannot configure gpg keys
1502+
;; - manage_mfa: a user cannot configure mfa devices
1503+
;; - manage_credentials: a user cannot configure emails, passwords, or openid
15001504
;;EXTERNAL_USER_DISABLE_FEATURES =
15011505

15021506
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

docs/content/administration/config-cheat-sheet.en-us.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -517,14 +517,18 @@ And the following unique queues:
517517

518518
- `DEFAULT_EMAIL_NOTIFICATIONS`: **enabled**: Default configuration for email notifications for users (user configurable). Options: enabled, onmention, disabled
519519
- `DISABLE_REGULAR_ORG_CREATION`: **false**: Disallow regular (non-admin) users from creating organizations.
520-
- `USER_DISABLED_FEATURES`: **_empty_** Disabled features for users, could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys` and more features can be added in future.
520+
- `USER_DISABLED_FEATURES`: **_empty_** Disabled features for users, could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`, `manage_mfa`, `manage_credentials` and more features can be added in future.
521521
- `deletion`: User cannot delete their own account.
522522
- `manage_ssh_keys`: User cannot configure ssh keys.
523523
- `manage_gpg_keys`: User cannot configure gpg keys.
524-
- `EXTERNAL_USER_DISABLE_FEATURES`: **_empty_**: Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior.
524+
- `manage_mfa`: a User cannot configure mfa devices.
525+
- `manage_credentials`: a user cannot configure emails, passwords, or openid
526+
- `EXTERNAL_USER_DISABLE_FEATURES`: **_empty_**: Comma separated list of disabled features ONLY if the user has an external login type (eg. LDAP, Oauth, etc.), could be `deletion`, `manage_ssh_keys`, `manage_gpg_keys`, `manage_mfa`, `manage_credentials`. This setting is independent from `USER_DISABLED_FEATURES` and supplements its behavior.
525527
- `deletion`: User cannot delete their own account.
526528
- `manage_ssh_keys`: User cannot configure ssh keys.
527529
- `manage_gpg_keys`: User cannot configure gpg keys.
530+
- `manage_mfa`: a User cannot configure mfa devices.
531+
- `manage_credentials`: a user cannot configure emails, passwords, or openid
528532

529533
## Security (`security`)
530534

models/user/user.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,12 +1233,14 @@ func GetOrderByName() string {
12331233
return "name"
12341234
}
12351235

1236-
// IsFeatureDisabledWithLoginType checks if a user feature is disabled, taking into account the login type of the
1236+
// IsFeatureDisabledWithLoginType checks if a user features are disabled, taking into account the login type of the
12371237
// user if applicable
1238-
func IsFeatureDisabledWithLoginType(user *User, feature string) bool {
1238+
func IsFeatureDisabledWithLoginType(user *User, features ...string) bool {
12391239
// NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType
1240-
return (user != nil && user.LoginType > auth.Plain && setting.Admin.ExternalUserDisableFeatures.Contains(feature)) ||
1241-
setting.Admin.UserDisabledFeatures.Contains(feature)
1240+
if user != nil && user.LoginType > auth.Plain {
1241+
return setting.Admin.ExternalUserDisableFeatures.Contains(features...)
1242+
}
1243+
return setting.Admin.UserDisabledFeatures.Contains(features...)
12421244
}
12431245

12441246
// DisabledFeaturesWithLoginType returns the set of user features disabled, taking into account the login type

modules/container/set.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
package container
55

6+
import "maps"
7+
68
type Set[T comparable] map[T]struct{}
79

810
// SetOf creates a set and adds the specified elements to it.
@@ -29,11 +31,15 @@ func (s Set[T]) AddMultiple(values ...T) {
2931
}
3032
}
3133

32-
// Contains determines whether a set contains the specified element.
34+
// Contains determines whether a set contains the specified elements.
3335
// Returns true if the set contains the specified element; otherwise, false.
34-
func (s Set[T]) Contains(value T) bool {
35-
_, has := s[value]
36-
return has
36+
func (s Set[T]) Contains(values ...T) bool {
37+
ret := true
38+
for _, value := range values {
39+
_, has := s[value]
40+
ret = ret && has
41+
}
42+
return ret
3743
}
3844

3945
// Remove removes the specified element.
@@ -54,3 +60,12 @@ func (s Set[T]) Values() []T {
5460
}
5561
return keys
5662
}
63+
64+
// Union constructs a new set that is the union of the provided sets
65+
func (s Set[T]) Union(sets ...Set[T]) Set[T] {
66+
newSet := maps.Clone(s)
67+
for i := range sets {
68+
maps.Copy(newSet, sets[i])
69+
}
70+
return newSet
71+
}

modules/setting/admin.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ func loadAdminFrom(rootCfg ConfigProvider) {
2020
Admin.DisableRegularOrgCreation = sec.Key("DISABLE_REGULAR_ORG_CREATION").MustBool(false)
2121
Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled")
2222
Admin.UserDisabledFeatures = container.SetOf(sec.Key("USER_DISABLED_FEATURES").Strings(",")...)
23-
Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...)
23+
Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...).Union(Admin.UserDisabledFeatures)
2424
}
2525

2626
const (
27-
UserFeatureDeletion = "deletion"
28-
UserFeatureManageSSHKeys = "manage_ssh_keys"
29-
UserFeatureManageGPGKeys = "manage_gpg_keys"
27+
UserFeatureDeletion = "deletion"
28+
UserFeatureManageSSHKeys = "manage_ssh_keys"
29+
UserFeatureManageGPGKeys = "manage_gpg_keys"
30+
UserFeatureManageMFA = "manage_mfa"
31+
UserFeatureManageCredentials = "manage_credentials"
3032
)

routers/web/repo/setting/secrets.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"net/http"
99

10+
user_model "code.gitea.io/gitea/models/user"
1011
"code.gitea.io/gitea/modules/base"
1112
"code.gitea.io/gitea/modules/setting"
1213
shared "code.gitea.io/gitea/routers/web/shared/secrets"
@@ -74,6 +75,7 @@ func Secrets(ctx *context.Context) {
7475
ctx.Data["Title"] = ctx.Tr("actions.actions")
7576
ctx.Data["PageType"] = "secrets"
7677
ctx.Data["PageIsSharedSettingsSecrets"] = true
78+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
7779

7880
sCtx, err := getSecretsCtx(ctx)
7981
if err != nil {

routers/web/user/setting/account.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package setting
66

77
import (
88
"errors"
9+
"fmt"
910
"net/http"
1011
"time"
1112

@@ -45,6 +46,11 @@ func Account(ctx *context.Context) {
4546

4647
// AccountPost response for change user's password
4748
func AccountPost(ctx *context.Context) {
49+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
50+
ctx.NotFound("Not Found", fmt.Errorf("password setting is not allowed to be changed"))
51+
return
52+
}
53+
4854
form := web.GetForm(ctx).(*forms.ChangePasswordForm)
4955
ctx.Data["Title"] = ctx.Tr("settings")
5056
ctx.Data["PageIsSettingsAccount"] = true
@@ -89,6 +95,11 @@ func AccountPost(ctx *context.Context) {
8995

9096
// EmailPost response for change user's email
9197
func EmailPost(ctx *context.Context) {
98+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
99+
ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed"))
100+
return
101+
}
102+
92103
form := web.GetForm(ctx).(*forms.AddEmailForm)
93104
ctx.Data["Title"] = ctx.Tr("settings")
94105
ctx.Data["PageIsSettingsAccount"] = true
@@ -216,6 +227,10 @@ func EmailPost(ctx *context.Context) {
216227

217228
// DeleteEmail response for delete user's email
218229
func DeleteEmail(ctx *context.Context) {
230+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageCredentials) {
231+
ctx.NotFound("Not Found", fmt.Errorf("emails are not allowed to be changed"))
232+
return
233+
}
219234
email, err := user_model.GetEmailAddressByID(ctx, ctx.Doer.ID, ctx.FormInt64("id"))
220235
if err != nil || email == nil {
221236
ctx.ServerError("GetEmailAddressByID", err)

routers/web/user/setting/applications.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
auth_model "code.gitea.io/gitea/models/auth"
1111
"code.gitea.io/gitea/models/db"
12+
user_model "code.gitea.io/gitea/models/user"
1213
"code.gitea.io/gitea/modules/base"
1314
"code.gitea.io/gitea/modules/setting"
1415
"code.gitea.io/gitea/modules/web"
@@ -24,6 +25,7 @@ const (
2425
func Applications(ctx *context.Context) {
2526
ctx.Data["Title"] = ctx.Tr("settings.applications")
2627
ctx.Data["PageIsSettingsApplications"] = true
28+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
2729

2830
loadApplicationsData(ctx)
2931

@@ -35,6 +37,7 @@ func ApplicationsPost(ctx *context.Context) {
3537
form := web.GetForm(ctx).(*forms.NewAccessTokenForm)
3638
ctx.Data["Title"] = ctx.Tr("settings")
3739
ctx.Data["PageIsSettingsApplications"] = true
40+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
3841

3942
if ctx.HasError() {
4043
loadApplicationsData(ctx)

routers/web/user/setting/keys.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,17 @@ const (
2525

2626
// Keys render user's SSH/GPG public keys page
2727
func Keys(ctx *context.Context) {
28+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys, setting.UserFeatureManageGPGKeys) {
29+
ctx.NotFound("Not Found", fmt.Errorf("keys setting is not allowed to be changed"))
30+
return
31+
}
32+
2833
ctx.Data["Title"] = ctx.Tr("settings.ssh_gpg_keys")
2934
ctx.Data["PageIsSettingsKeys"] = true
3035
ctx.Data["DisableSSH"] = setting.SSH.Disabled
3136
ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
3237
ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
38+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
3339

3440
loadKeysData(ctx)
3541

@@ -44,6 +50,7 @@ func KeysPost(ctx *context.Context) {
4450
ctx.Data["DisableSSH"] = setting.SSH.Disabled
4551
ctx.Data["BuiltinSSH"] = setting.SSH.StartBuiltinServer
4652
ctx.Data["AllowPrincipals"] = setting.SSH.AuthorizedPrincipalsEnabled
53+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
4754

4855
if ctx.HasError() {
4956
loadKeysData(ctx)

routers/web/user/setting/packages.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
func Packages(ctx *context.Context) {
2626
ctx.Data["Title"] = ctx.Tr("packages.title")
2727
ctx.Data["PageIsSettingsPackages"] = true
28+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
2829

2930
shared.SetPackagesContext(ctx, ctx.Doer)
3031

@@ -34,6 +35,7 @@ func Packages(ctx *context.Context) {
3435
func PackagesRuleAdd(ctx *context.Context) {
3536
ctx.Data["Title"] = ctx.Tr("packages.title")
3637
ctx.Data["PageIsSettingsPackages"] = true
38+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
3739

3840
shared.SetRuleAddContext(ctx)
3941

@@ -43,6 +45,7 @@ func PackagesRuleAdd(ctx *context.Context) {
4345
func PackagesRuleEdit(ctx *context.Context) {
4446
ctx.Data["Title"] = ctx.Tr("packages.title")
4547
ctx.Data["PageIsSettingsPackages"] = true
48+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
4649

4750
shared.SetRuleEditContext(ctx, ctx.Doer)
4851

@@ -52,6 +55,7 @@ func PackagesRuleEdit(ctx *context.Context) {
5255
func PackagesRuleAddPost(ctx *context.Context) {
5356
ctx.Data["Title"] = ctx.Tr("settings")
5457
ctx.Data["PageIsSettingsPackages"] = true
58+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
5559

5660
shared.PerformRuleAddPost(
5761
ctx,
@@ -64,6 +68,7 @@ func PackagesRuleAddPost(ctx *context.Context) {
6468
func PackagesRuleEditPost(ctx *context.Context) {
6569
ctx.Data["Title"] = ctx.Tr("packages.title")
6670
ctx.Data["PageIsSettingsPackages"] = true
71+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
6772

6873
shared.PerformRuleEditPost(
6974
ctx,
@@ -76,6 +81,7 @@ func PackagesRuleEditPost(ctx *context.Context) {
7681
func PackagesRulePreview(ctx *context.Context) {
7782
ctx.Data["Title"] = ctx.Tr("packages.title")
7883
ctx.Data["PageIsSettingsPackages"] = true
84+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
7985

8086
shared.SetRulePreviewContext(ctx, ctx.Doer)
8187

routers/web/user/setting/profile.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ func Profile(ctx *context.Context) {
4848
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
4949
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
5050

51+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
52+
5153
ctx.HTML(http.StatusOK, tplSettingsProfile)
5254
}
5355

@@ -57,6 +59,7 @@ func ProfilePost(ctx *context.Context) {
5759
ctx.Data["PageIsSettingsProfile"] = true
5860
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
5961
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
62+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
6063

6164
if ctx.HasError() {
6265
ctx.HTML(http.StatusOK, tplSettingsProfile)
@@ -182,6 +185,7 @@ func DeleteAvatar(ctx *context.Context) {
182185
func Organization(ctx *context.Context) {
183186
ctx.Data["Title"] = ctx.Tr("settings.organization")
184187
ctx.Data["PageIsSettingsOrganization"] = true
188+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
185189

186190
opts := organization.FindOrgOptions{
187191
ListOptions: db.ListOptions{
@@ -213,6 +217,7 @@ func Organization(ctx *context.Context) {
213217
func Repos(ctx *context.Context) {
214218
ctx.Data["Title"] = ctx.Tr("settings.repos")
215219
ctx.Data["PageIsSettingsRepos"] = true
220+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
216221
ctx.Data["allowAdopt"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowAdoptionOfUnadoptedRepositories
217222
ctx.Data["allowDelete"] = ctx.IsUserSiteAdmin() || setting.Repository.AllowDeleteOfUnadoptedRepositories
218223

@@ -326,6 +331,7 @@ func Appearance(ctx *context.Context) {
326331
allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top
327332
}
328333
ctx.Data["AllThemes"] = allThemes
334+
ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
329335

330336
var hiddenCommentTypes *big.Int
331337
val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes)

routers/web/user/setting/security/2fa.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"code.gitea.io/gitea/models/auth"
16+
user_model "code.gitea.io/gitea/models/user"
1617
"code.gitea.io/gitea/modules/log"
1718
"code.gitea.io/gitea/modules/setting"
1819
"code.gitea.io/gitea/modules/web"
@@ -25,6 +26,11 @@ import (
2526

2627
// RegenerateScratchTwoFactor regenerates the user's 2FA scratch code.
2728
func RegenerateScratchTwoFactor(ctx *context.Context) {
29+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
30+
ctx.Error(http.StatusNotFound)
31+
return
32+
}
33+
2834
ctx.Data["Title"] = ctx.Tr("settings")
2935
ctx.Data["PageIsSettingsSecurity"] = true
3036

@@ -55,6 +61,11 @@ func RegenerateScratchTwoFactor(ctx *context.Context) {
5561

5662
// DisableTwoFactor deletes the user's 2FA settings.
5763
func DisableTwoFactor(ctx *context.Context) {
64+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
65+
ctx.Error(http.StatusNotFound)
66+
return
67+
}
68+
5869
ctx.Data["Title"] = ctx.Tr("settings")
5970
ctx.Data["PageIsSettingsSecurity"] = true
6071

@@ -142,6 +153,11 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool {
142153

143154
// EnrollTwoFactor shows the page where the user can enroll into 2FA.
144155
func EnrollTwoFactor(ctx *context.Context) {
156+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
157+
ctx.Error(http.StatusNotFound)
158+
return
159+
}
160+
145161
ctx.Data["Title"] = ctx.Tr("settings")
146162
ctx.Data["PageIsSettingsSecurity"] = true
147163

@@ -167,6 +183,11 @@ func EnrollTwoFactor(ctx *context.Context) {
167183

168184
// EnrollTwoFactorPost handles enrolling the user into 2FA.
169185
func EnrollTwoFactorPost(ctx *context.Context) {
186+
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageMFA) {
187+
ctx.Error(http.StatusNotFound)
188+
return
189+
}
190+
170191
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
171192
ctx.Data["Title"] = ctx.Tr("settings")
172193
ctx.Data["PageIsSettingsSecurity"] = true

0 commit comments

Comments
 (0)