diff --git a/models/auth/access_token_scope.go b/models/auth/access_token_scope.go index 897ff3fc9e..0e5b2e96e6 100644 --- a/models/auth/access_token_scope.go +++ b/models/auth/access_token_scope.go @@ -5,6 +5,7 @@ package auth import ( "fmt" + "slices" "strings" "code.gitea.io/gitea/models/perm" @@ -14,7 +15,7 @@ import ( type AccessTokenScopeCategory int const ( - AccessTokenScopeCategoryActivityPub = iota + AccessTokenScopeCategoryActivityPub AccessTokenScopeCategory = iota AccessTokenScopeCategoryAdmin AccessTokenScopeCategoryMisc // WARN: this is now just a placeholder, don't remove it which will change the following values AccessTokenScopeCategoryNotification @@ -193,6 +194,14 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A }, } +func GetAccessTokenCategories() (res []string) { + for _, cat := range accessTokenScopes[Read] { + res = append(res, strings.TrimPrefix(string(cat), "read:")) + } + slices.Sort(res) + return res +} + // GetRequiredScopes gets the specific scopes for a given level and categories func GetRequiredScopes(level AccessTokenScopeLevel, scopeCategories ...AccessTokenScopeCategory) []AccessTokenScope { scopes := make([]AccessTokenScope, 0, len(scopeCategories)) @@ -270,6 +279,9 @@ func (s AccessTokenScope) parse() (accessTokenScopeBitmap, error) { // StringSlice returns the AccessTokenScope as a []string func (s AccessTokenScope) StringSlice() []string { + if s == "" { + return nil + } return strings.Split(string(s), ",") } diff --git a/models/auth/access_token_scope_test.go b/models/auth/access_token_scope_test.go index a6097e45d7..9e4aa83633 100644 --- a/models/auth/access_token_scope_test.go +++ b/models/auth/access_token_scope_test.go @@ -17,6 +17,7 @@ type scopeTestNormalize struct { } func TestAccessTokenScope_Normalize(t *testing.T) { + assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories()) tests := []scopeTestNormalize{ {"", "", nil}, {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, @@ -25,7 +26,7 @@ func TestAccessTokenScope_Normalize(t *testing.T) { {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil}, } - for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + for _, scope := range GetAccessTokenCategories() { tests = append(tests, scopeTestNormalize{AccessTokenScope(fmt.Sprintf("read:%s", scope)), AccessTokenScope(fmt.Sprintf("read:%s", scope)), nil}, scopeTestNormalize{AccessTokenScope(fmt.Sprintf("write:%s", scope)), AccessTokenScope(fmt.Sprintf("write:%s", scope)), nil}, @@ -59,7 +60,7 @@ func TestAccessTokenScope_HasScope(t *testing.T) { {"public-only", "read:issue", false, nil}, } - for _, scope := range []string{"activitypub", "admin", "misc", "notification", "organization", "package", "issue", "repository", "user"} { + for _, scope := range GetAccessTokenCategories() { tests = append(tests, scopeTestHasScope{ AccessTokenScope(fmt.Sprintf("read:%s", scope)), diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4c25ba3205..5aa6d19dae 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -917,7 +917,6 @@ delete_token_success = The token has been deleted. Applications using it no long repo_and_org_access = Repository and Organization Access permissions_public_only = Public only permissions_access_all = All (public, private, and limited) -select_permissions = Select permissions permission_not_set = Not set permission_no_access = No Access permission_read = Read diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index cf71d01dc1..1f6c97a5cc 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -6,12 +6,14 @@ package setting import ( "net/http" + "strings" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -39,18 +41,29 @@ func ApplicationsPost(ctx *context.Context) { ctx.Data["PageIsSettingsApplications"] = true ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer) - if ctx.HasError() { - loadApplicationsData(ctx) - - ctx.HTML(http.StatusOK, tplSettingsApplications) - return + _ = ctx.Req.ParseForm() + var scopeNames []string + for k, v := range ctx.Req.Form { + if strings.HasPrefix(k, "scope-") { + scopeNames = append(scopeNames, v...) + } } - scope, err := form.GetScope() + scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize() if err != nil { ctx.ServerError("GetScope", err) return } + if scope == "" || scope == auth_model.AccessTokenScopePublicOnly { + ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) + } + + if ctx.HasError() { + loadApplicationsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + t := &auth_model.AccessToken{ UID: ctx.Doer.ID, Name: form.Name, @@ -99,7 +112,14 @@ func loadApplicationsData(ctx *context.Context) { } ctx.Data["Tokens"] = tokens ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled - ctx.Data["IsAdmin"] = ctx.Doer.IsAdmin + + // Handle specific ordered token categories for admin or non-admin users + tokenCategoryNames := auth_model.GetAccessTokenCategories() + if !ctx.Doer.IsAdmin { + tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin") + } + ctx.Data["TokenCategories"] = tokenCategoryNames + if setting.OAuth2.Enabled { ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ OwnerID: ctx.Doer.ID, diff --git a/services/context/context.go b/services/context/context.go index 5e08fba442..f3a0f0bb5f 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -213,13 +213,16 @@ func Contexter() func(next http.Handler) http.Handler { // Attention: this function changes ctx.Data and ctx.Flash // If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again. func (ctx *Context) HasError() bool { - hasErr, ok := ctx.Data["HasError"] - if !ok { + hasErr, _ := ctx.Data["HasError"].(bool) + hasErr = hasErr || ctx.Flash.ErrorMsg != "" + if !hasErr { return false } - ctx.Flash.ErrorMsg = ctx.GetErrMsg() + if ctx.Flash.ErrorMsg == "" { + ctx.Flash.ErrorMsg = ctx.GetErrMsg() + } ctx.Data["Flash"] = ctx.Flash - return hasErr.(bool) + return hasErr } // GetErrMsg returns error message in form validation. diff --git a/services/forms/user_form.go b/services/forms/user_form.go index ed79936add..c9ce71e886 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -7,9 +7,7 @@ package forms import ( "mime/multipart" "net/http" - "strings" - auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" @@ -347,8 +345,7 @@ func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) bind // NewAccessTokenForm form for creating access token type NewAccessTokenForm struct { - Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"` - Scope []string + Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"` } // Validate validates the fields @@ -357,12 +354,6 @@ func (f *NewAccessTokenForm) Validate(req *http.Request, errs binding.Errors) bi return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } -func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) { - scope := strings.Join(f.Scope, ",") - s, err := auth_model.AccessTokenScope(scope).Normalize() - return s, err -} - // EditOAuth2ApplicationForm form for editing oauth2 applications type EditOAuth2ApplicationForm struct { Name string `binding:"Required;MaxSize(255)" form:"application_name"` diff --git a/services/forms/user_form_test.go b/services/forms/user_form_test.go index 66050187c9..b4120f20ed 100644 --- a/services/forms/user_form_test.go +++ b/services/forms/user_form_test.go @@ -4,10 +4,8 @@ package forms import ( - "strconv" "testing" - auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/setting" "github.com/gobwas/glob" @@ -104,28 +102,3 @@ func TestRegisterForm_IsDomainAllowed_BlockedEmail(t *testing.T) { assert.Equal(t, v.valid, form.IsEmailDomainAllowed()) } } - -func TestNewAccessTokenForm_GetScope(t *testing.T) { - tests := []struct { - form NewAccessTokenForm - scope auth_model.AccessTokenScope - expectedErr error - }{ - { - form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository"}}, - scope: "read:repository", - }, - { - form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository", "write:user"}}, - scope: "read:repository,write:user", - }, - } - - for i, test := range tests { - t.Run(strconv.Itoa(i), func(t *testing.T) { - scope, err := test.form.GetScope() - assert.Equal(t, test.expectedErr, err) - assert.Equal(t, test.scope, scope) - }) - } -} diff --git a/templates/user/settings/applications.tmpl b/templates/user/settings/applications.tmpl index 31d1a2ac5b..501f238c7a 100644 --- a/templates/user/settings/applications.tmpl +++ b/templates/user/settings/applications.tmpl @@ -50,49 +50,41 @@
-
- {{ctx.Locale.Tr "settings.generate_new_token"}} -
-
- {{.CsrfTokenHtml}} -
- - -
-
- - - -
-
- - {{ctx.Locale.Tr "settings.select_permissions"}} - -

- {{ctx.Locale.Tr "settings.access_token_desc" (HTMLFormat `href="%s/api/swagger" target="_blank"` AppSubUrl) (`href="https://docs.gitea.com/development/oauth2-provider#scopes" target="_blank"`|SafeHTML)}} -

-
+
+

{{ctx.Locale.Tr "settings.generate_new_token"}}

+ + {{.CsrfTokenHtml}} +
+ +
-
- - {{/* Fomantic ".ui.form .warning.message" is hidden by default, so put the warning message out of the form*/}} -
- {{ctx.Locale.Tr "settings.at_least_one_permission"}} -
+
+
{{ctx.Locale.Tr "settings.repo_and_org_access"}}
+ + +
+
+
{{ctx.Locale.Tr "settings.access_token_desc" (HTMLFormat `href="%s/api/swagger" target="_blank"` AppSubUrl) (`href="https://docs.gitea.com/development/oauth2-provider#scopes" target="_blank"`|SafeHTML)}}
+ + {{range $category := .TokenCategories}} + + + + + + + {{end}} +
{{$category}}
+
+ + +
{{if .EnableOAuth2}} diff --git a/templates/user/settings/applications_oauth2_list.tmpl b/templates/user/settings/applications_oauth2_list.tmpl index 61098e118b..418d8e9cfc 100644 --- a/templates/user/settings/applications_oauth2_list.tmpl +++ b/templates/user/settings/applications_oauth2_list.tmpl @@ -48,33 +48,33 @@
-
- {{ctx.Locale.Tr "settings.create_oauth2_application"}} -
-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
-
-
- - +
+

{{ctx.Locale.Tr "settings.create_oauth2_application"}}

+ + {{.CsrfTokenHtml}} +
+ +
-
-
-
- - +
+ +
-
- - +
+
+ + +
+
+
+
+ + +
+
+ + +
diff --git a/tests/integration/api_admin_org_test.go b/tests/integration/api_admin_org_test.go index a29d0ba1d7..b243856127 100644 --- a/tests/integration/api_admin_org_test.go +++ b/tests/integration/api_admin_org_test.go @@ -76,7 +76,7 @@ func TestAPIAdminOrgCreateNotAdmin(t *testing.T) { defer tests.PrepareTestEnv(t)() nonAdminUsername := "user2" session := loginUser(t, nonAdminUsername) - token := getTokenForLoggedInUser(t, session) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) org := api.CreateOrgOption{ UserName: "user2_org", FullName: "User2's organization", diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index 66209ee4e0..b42f05fc55 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -76,7 +76,7 @@ func TestAPIAdminDeleteUnauthorizedKey(t *testing.T) { var newPublicKey api.PublicKey DecodeJSON(t, resp, &newPublicKey) - token = getUserToken(t, normalUsername) + token = getUserToken(t, normalUsername, auth_model.AccessTokenScopeAll) req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", adminUsername, newPublicKey.ID). AddTokenAuth(token) MakeRequest(t, req, http.StatusForbidden) @@ -139,7 +139,7 @@ func TestAPIListUsersNotLoggedIn(t *testing.T) { func TestAPIListUsersNonAdmin(t *testing.T) { defer tests.PrepareTestEnv(t)() nonAdminUsername := "user2" - token := getUserToken(t, nonAdminUsername) + token := getUserToken(t, nonAdminUsername, auth_model.AccessTokenScopeAll) req := NewRequest(t, "GET", "/api/v1/admin/users"). AddTokenAuth(token) MakeRequest(t, req, http.StatusForbidden) diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go index 96669b46f0..f3a595540f 100644 --- a/tests/integration/api_helper_for_declarative_test.go +++ b/tests/integration/api_helper_for_declarative_test.go @@ -33,6 +33,10 @@ type APITestContext struct { func NewAPITestContext(t *testing.T, username, reponame string, scope ...auth.AccessTokenScope) APITestContext { session := loginUser(t, username) + if len(scope) == 0 { + // FIXME: legacy logic: no scope means all + scope = []auth.AccessTokenScope{auth.AccessTokenScopeAll} + } token := getTokenForLoggedInUser(t, session, scope...) return APITestContext{ Session: session, diff --git a/tests/integration/api_repo_git_blobs_test.go b/tests/integration/api_repo_git_blobs_test.go index 184362e7e3..9c4be31396 100644 --- a/tests/integration/api_repo_git_blobs_test.go +++ b/tests/integration/api_repo_git_blobs_test.go @@ -72,7 +72,7 @@ func TestAPIReposGitBlobs(t *testing.T) { // Login as User4. session = loginUser(t, user4.Name) - token4 := getTokenForLoggedInUser(t, session) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) // Test using org repo "org3/repo3" where user4 is a NOT collaborator req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4) diff --git a/tests/integration/api_repo_git_trees_test.go b/tests/integration/api_repo_git_trees_test.go index 8eec6d8d22..47063d9091 100644 --- a/tests/integration/api_repo_git_trees_test.go +++ b/tests/integration/api_repo_git_trees_test.go @@ -69,7 +69,7 @@ func TestAPIReposGitTrees(t *testing.T) { // Login as User4. session = loginUser(t, user4.Name) - token4 := getTokenForLoggedInUser(t, session) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) // Test using org repo "org3/repo3" where user4 is a NOT collaborator req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4) diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 9e487924d1..2f6b7eae31 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -249,55 +249,19 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession // token has to be unique this counter take care of var tokenCounter int64 -// getTokenForLoggedInUser returns a token for a logged in user. -// The scope is an optional list of snake_case strings like the frontend form fields, -// but without the "scope_" prefix. +// getTokenForLoggedInUser returns a token for a logged-in user. func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string { t.Helper() - var token string - req := NewRequest(t, "GET", "/user/settings/applications") - resp := session.MakeRequest(t, req, http.StatusOK) - var csrf string - for _, cookie := range resp.Result().Cookies() { - if cookie.Name != "_csrf" { - continue - } - csrf = cookie.Value - break - } - if csrf == "" { - doc := NewHTMLParser(t, resp.Body) - csrf = doc.GetCSRF() - } - assert.NotEmpty(t, csrf) urlValues := url.Values{} - urlValues.Add("_csrf", csrf) + urlValues.Add("_csrf", GetUserCSRFToken(t, session)) urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1))) for _, scope := range scopes { - urlValues.Add("scope", string(scope)) + urlValues.Add("scope-dummy", string(scope)) // it only needs to start with "scope-" to be accepted } - req = NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues) - resp = session.MakeRequest(t, req, http.StatusSeeOther) - - // Log the flash values on failure - if !assert.Equal(t, []string{"/user/settings/applications"}, resp.Result().Header["Location"]) { - for _, cookie := range resp.Result().Cookies() { - if cookie.Name != gitea_context.CookieNameFlash { - continue - } - flash, _ := url.ParseQuery(cookie.Value) - for key, value := range flash { - t.Logf("Flash %q: %q", key, value) - } - } - } - - req = NewRequest(t, "GET", "/user/settings/applications") - resp = session.MakeRequest(t, req, http.StatusOK) - htmlDoc := NewHTMLParser(t, resp.Body) - token = htmlDoc.doc.Find(".ui.info p").Text() - assert.NotEmpty(t, token) - return token + req := NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues) + session.MakeRequest(t, req, http.StatusSeeOther) + flashes := session.GetCookieFlashMessage() + return flashes.InfoMsg } type RequestWrapper struct { diff --git a/web_src/css/index.css b/web_src/css/index.css index ce1a23b245..630aa3c2ef 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -19,7 +19,6 @@ @import "./modules/dimmer.css"; @import "./modules/modal.css"; -@import "./modules/select.css"; @import "./modules/tippy.css"; @import "./modules/breadcrumb.css"; @import "./modules/comment.css"; diff --git a/web_src/css/modules/checkbox.css b/web_src/css/modules/checkbox.css index 0a3a71acaa..f7e61ba360 100644 --- a/web_src/css/modules/checkbox.css +++ b/web_src/css/modules/checkbox.css @@ -119,3 +119,13 @@ input[type="radio"] { .ui.toggle.checkbox input:focus:checked ~ label::before { background: var(--color-primary) !important; } + +label.gt-checkbox { + display: inline-flex; + align-items: center; + gap: 0.25em; +} + +.ui.form .field > label.gt-checkbox { + display: flex; +} diff --git a/web_src/css/modules/select.css b/web_src/css/modules/select.css deleted file mode 100644 index 1d7d749d4a..0000000000 --- a/web_src/css/modules/select.css +++ /dev/null @@ -1,25 +0,0 @@ -.gitea-select { - position: relative; -} - -.gitea-select select { - appearance: none; /* hide default triangle */ -} - -/* ::before and ::after pseudo elements don't work on select elements, - so we need to put it on the parent. */ -.gitea-select::after { - position: absolute; - top: 12px; - right: 8px; - pointer-events: none; - content: ""; - width: 14px; - height: 14px; - mask-size: cover; - -webkit-mask-size: cover; - mask-image: var(--octicon-chevron-right); - -webkit-mask-image: var(--octicon-chevron-right); - transform: rotate(90deg); /* point the chevron down */ - background: currentcolor; -} diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue deleted file mode 100644 index 9eaf824035..0000000000 --- a/web_src/js/components/ScopedAccessTokenSelector.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/web_src/js/features/scoped-access-token.ts b/web_src/js/features/scoped-access-token.ts deleted file mode 100644 index c498d4c011..0000000000 --- a/web_src/js/features/scoped-access-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -import {createApp} from 'vue'; - -export async function initScopedAccessTokenCategories() { - const el = document.querySelector('#scoped-access-token-selector'); - if (!el) return; - - const {default: ScopedAccessTokenSelector} = await import(/* webpackChunkName: "scoped-access-token-selector" */'../components/ScopedAccessTokenSelector.vue'); - try { - const View = createApp(ScopedAccessTokenSelector, { - isAdmin: JSON.parse(el.getAttribute('data-is-admin')), - noAccessLabel: el.getAttribute('data-no-access-label'), - readLabel: el.getAttribute('data-read-label'), - writeLabel: el.getAttribute('data-write-label'), - }); - View.mount(el); - } catch (err) { - console.error('ScopedAccessTokenSelector failed to load', err); - el.textContent = el.getAttribute('data-locale-component-failed-to-load'); - } -} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index b89e596047..032e6ffe1b 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -68,7 +68,6 @@ import {initColorPickers} from './features/colorpicker.ts'; import {initAdminSelfCheck} from './features/admin/selfcheck.ts'; import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; import {initGlobalFetchAction} from './features/common-fetch-action.ts'; -import {initScopedAccessTokenCategories} from './features/scoped-access-token.ts'; import { initFootLanguageMenu, initGlobalDropdown, @@ -209,7 +208,6 @@ onDomReady(() => { initUserSettings, initRepoDiffView, initPdfViewer, - initScopedAccessTokenCategories, initColorPickers, initOAuth2SettingsDisableCheckbox,