feat(ui): create repo-specific access tokens (#11696)

Adds a user interface for creating repo-specific access tokens (#11311).  When the new option "Specific repositories" is selected, a search option appears.  Each repository in the search result has an "Add" button to include it on the access token, and once included, a repository can be removed with the "Remove" button.  This is a JS-free form.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

(can be removed for JavaScript changes)

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [x] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/
README.md#end-to-end-tests)).
    - Technically there are no "JavaScript changes" in this PR, but e2e tests were added for browser interaction testing.

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
    - TODO: planning to create documentation in https://forgejo.org/docs/next/user/token-scope/; there is none for public only tokens but I think this seems like a good place to add both.
- [ ] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11696
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2026-03-23 15:29:08 +01:00 committed by Mathieu Fenniak
commit 35b872f383
15 changed files with 798 additions and 95 deletions

View file

@ -188,6 +188,9 @@ type SearchRepoOptions struct {
OnlyShowRelevant bool
// Filters repositories based upon optional authorization restrictions.
AuthorizationReducer RepositoryAuthorizationReducer
// Retrieve multiple repositories by their owner name & repository name, similar to [GetRepositoryByOwnerAndName]
// but in bulk.
OwnerAndName [][2]string
}
// UserOwnedRepoCond returns user ownered repositories
@ -495,6 +498,26 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
cond = cond.And(opts.AuthorizationReducer.RepoReadAccessFilter())
}
if opts.OwnerAndName != nil {
if len(opts.OwnerAndName) > 0 {
// repository is indexed on `(owner_id, lower_name)`, but not on the `owner_name` field. Plus the `owner_name`
// field isn't ToLower'd. So this becomes a subquery:
subQuery := builder.Select("inner_repo.id").From("repository", "inner_repo").
Join("INNER", "`user`", "`user`.id = inner_repo.owner_id")
for _, ownerAndName := range opts.OwnerAndName {
subQuery.Or(builder.Eq{
"`user`.lower_name": strings.ToLower(ownerAndName[0]),
"inner_repo.lower_name": strings.ToLower(ownerAndName[1]),
})
}
cond = cond.And(builder.In("id", subQuery))
} else {
// If opts.OwnerAndName is a non-nil, empty array, then we want to return zero repositories. The loop to
// build the `Eq` conditions wouldn't occur, so we would have no filtering if this wasn't special-case'd.
cond = cond.And(builder.Eq{"1": "2"})
}
}
return cond
}

View file

@ -179,6 +179,26 @@ func getTestCases() []struct {
opts: &repo_model.SearchRepoOptions{Keyword: "user20/", ListOptions: db.ListOptions{Page: 1, PageSize: 10}, Private: true, OwnerID: 0},
count: 4,
},
{
name: "OwnerAndName Single",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{{"user15", "big_test_public_1"}}},
count: 1,
},
{
name: "OwnerAndName Multiple",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{{"user15", "big_test_public_1"}, {"user15", "big_test_public_2"}}},
count: 2,
},
{
name: "OwnerAndName Miss",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{{"user15", "big_test_public_1"}, {"user15", "blah blah"}}},
count: 1,
},
{
name: "OwnerAndName Empty",
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{}},
count: 0,
},
}
return testCases

View file

@ -193,6 +193,19 @@
"settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.",
"settings.specific_repo_access": "Repository access",
"settings.new_access_token": "New access token",
"settings.permissions_access_specific_repositories": "Specific repositories",
"settings.access_token.selected_repositories": {
"one": "Selected repository (%d)",
"other": "Selected repositories (%d)"
},
"settings.access_token.available_repositories": "Available repositories",
"settings.access_token.no_repositories_selected": "No repositories selected.",
"settings.access_token.no_repositories_found": "No repositories found.",
"settings.access_token.remove": "Remove %s",
"settings.access_token.resource_all_help": "Allow access to all resources.",
"settings.access_token.resource_public_only_help": "Limit access to repositories and organizations that are public.",
"settings.access_token.resource_specific_repo_help": "Limit access to a specific list of repositories. Read-only access is permitted to all public repositories. Only permissions that allow access to repositories and issues can be enabled.",
"settings.access_token.admin_disabled": "Administrative permissions are disabled.",
"error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s",
"avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels",
"user.ghost.tooltip": "This user has been deleted, or cannot be matched.",

View file

@ -18,10 +18,10 @@ import (
"forgejo.org/models/db"
access_model "forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/optional"
api "forgejo.org/modules/structs"
"forgejo.org/modules/web"
"forgejo.org/routers/api/v1/utils"
"forgejo.org/routers/web/shared/user"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/convert"
@ -115,19 +115,6 @@ func ListAccessTokens(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, &apiTokens)
}
func translateAccessTokenValidationError(ctx *context.Base, err error) optional.Option[string] {
switch {
case errors.Is(err, authz.ErrSpecifiedReposNone):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_none"))
case errors.Is(err, authz.ErrSpecifiedReposNoPublicOnly):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_public_only"))
case errors.Is(err, authz.ErrSpecifiedReposInvalidScope):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_invalid_scope"))
default:
return optional.None[string]()
}
}
// CreateAccessToken creates an access token
func CreateAccessToken(ctx *context.APIContext) {
// swagger:operation POST /users/{username}/tokens user userCreateToken
@ -228,7 +215,7 @@ func CreateAccessToken(ctx *context.APIContext) {
}
if err := authz.ValidateAccessToken(t, resourceRepos); err != nil {
s := translateAccessTokenValidationError(ctx.Base, err)
s := user.TranslateAccessTokenValidationError(ctx.Base, err)
if has, str := s.Get(); has {
ctx.Error(http.StatusBadRequest, "ValidateAccessToken", str)
return

View file

@ -0,0 +1,28 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package user
import (
"errors"
"forgejo.org/modules/optional"
"forgejo.org/services/authz"
"forgejo.org/services/context"
)
// Translate errors from [ValidateAccessToken] into user-visible messages, if the error is expected as a user-facing
// error. None[string] may be returned to indicate the error is an unexpected server-side error, not a user validation
// error.
func TranslateAccessTokenValidationError(ctx *context.Base, err error) optional.Option[string] {
switch {
case errors.Is(err, authz.ErrSpecifiedReposNone):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_none"))
case errors.Is(err, authz.ErrSpecifiedReposNoPublicOnly):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_public_only"))
case errors.Is(err, authz.ErrSpecifiedReposInvalidScope):
return optional.Some[string](ctx.Locale.TrString("access_token.error.specified_repos_and_invalid_scope"))
default:
return optional.None[string]()
}
}

View file

@ -1,26 +1,84 @@
// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
stdCtx "context"
"fmt"
"html/template"
"net/http"
"slices"
"strings"
auth_model "forgejo.org/models/auth"
"forgejo.org/models/db"
access_model "forgejo.org/models/perm/access"
repo_model "forgejo.org/models/repo"
"forgejo.org/modules/base"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/setting"
"forgejo.org/modules/web"
"forgejo.org/routers/web/shared/user"
"forgejo.org/services/authz"
"forgejo.org/services/context"
"forgejo.org/services/forms"
"xorm.io/builder"
)
const (
tplAccessTokenEdit base.TplName = "user/settings/access_token_edit"
)
func getSelectedRepos(ctx *context.Context, selectedReposRaw []string) []*repo_model.Repository {
ownerAndName := make([][2]string, len(selectedReposRaw))
for i, selected := range selectedReposRaw {
split := strings.SplitN(selected, "/", 2) // ownername/reponame
if len(split) != 2 {
ctx.Error(http.StatusBadRequest, fmt.Sprintf("invalid selected_repo: %s", selected))
return nil
}
ownerAndName[i] = [2]string{split[0], split[1]}
}
repoSearch := &repo_model.SearchRepoOptions{
OwnerAndName: ownerAndName,
OrderBy: db.SearchOrderByAlphabetically, // match sorting in loadAccessTokenCreateData for consistency
Private: true,
}
cond := repo_model.SearchRepositoryCondition(repoSearch)
repos, _, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
if err != nil {
ctx.ServerError("SearchRepositoryByCondition", err)
return nil
} else if len(repos) != len(selectedReposRaw) {
// One or more of the repositories couldn't be found by search by owner & name.
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName") // keep in sync w/ !permission.HasAccess below
return nil
}
selectedRepos := make([]*repo_model.Repository, len(selectedReposRaw))
for i, repo := range repos {
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return nil
} else if !permission.HasAccess() {
// Prevent data existence probing -- ensure this error is the exact same as the (len(repos) !=
// len(selectedReposRaw)) case above
ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName")
return nil
}
selectedRepos[i] = repo
}
return selectedRepos
}
func loadAccessTokenCreateData(ctx *context.Context) {
ctx.Data["AccessTokenScopePublicOnly"] = string(auth_model.AccessTokenScopePublicOnly) // note: SliceUtils.Contains won't work in the template if this is a `auth_model.AccessTokenScope`, so it's cast to a string here
@ -39,6 +97,113 @@ func loadAccessTokenCreateData(ctx *context.Context) {
}
slices.Sort(categories)
ctx.Data["Categories"] = categories
// Awkward -- GET and POST use different form bindings for the reasons explained on NewAccessTokenGetForm -- and
// this method can be called in both situations, on all GETs, and on some POSTs when validation errors occur. So
// both forms need to be handled here.
getForm, isGet := web.GetForm(ctx).(*forms.NewAccessTokenGetForm)
postForm, isPost := web.GetForm(ctx).(*forms.NewAccessTokenPostForm)
if isGet {
// Manage the result of adding or removing a repository before we do anything with `form.SelectedRepo`...
changed := false
if getForm.AddSelectedRepo != "" {
getForm.SelectedRepo = append(getForm.SelectedRepo, getForm.AddSelectedRepo)
changed = true
}
if getForm.RemoveSelectedRepo != "" {
getForm.SelectedRepo = slices.DeleteFunc(
getForm.SelectedRepo,
func(r string) bool { return r == getForm.RemoveSelectedRepo },
)
changed = true
}
if changed {
// We've changed getForm.SelectedRepo, but a reference to this slice was already present in `ctx.Data` (the
// Bind middleware invokes AssignForm to put getForm values into `ctx.Data`). Replace the reference:
ctx.Data["selected_repo"] = getForm.SelectedRepo
}
}
repoSearchText := ""
if isGet {
repoSearchText = getForm.RepoSearch
}
selectedReposRaw := []string{}
if isGet {
selectedReposRaw = getForm.SelectedRepo
} else if isPost {
selectedReposRaw = postForm.SelectedRepo
}
selectedRepos := getSelectedRepos(ctx, selectedReposRaw)
if ctx.Written() {
return
}
ctx.Data["SelectedRepos"] = selectedRepos
page := 1
if isGet {
// Pagination on the repo search has form submit buttons that send the `set_page` param. It's then encoded into
// the page in the hidden input `page` which we fall back to, if anything else causes a form get (eg. adding or
// removing a repo).
if getForm.SetPage > 0 {
page = getForm.SetPage
} else if getForm.Page > 0 {
page = getForm.Page
}
}
pageSize := 10
repoSearch := &repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Keyword: repoSearchText,
Private: true,
Archived: optional.Some(false),
// Restrict repositories to those owned by, or collaborated with, by the user. Repo-specific access tokens
// could theoretically be created on any public repository as well, but there wouldn't be much point to that and
// it would really balloon the search results to an impractical number of repos.
OwnerID: ctx.Doer.ID,
ListOptions: db.ListOptions{
Page: page,
PageSize: pageSize,
},
OrderBy: db.SearchOrderByAlphabetically, // match sorting in getSelectedRepos for consistency
}
cond := repo_model.SearchRepositoryCondition(repoSearch)
// Exclude all the repos that are currently in `form.SelectedRepo` from the search, by omitting them from the search
// condition. This prevents the UI from displaying the same repo on the left and right, and maintains the repo
// search and page-size correctly.
for _, selected := range selectedRepos {
cond = cond.And(builder.Neq{"id": selected.ID})
}
repos, count, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
if err != nil {
log.Error("SearchRepository: %v", err)
ctx.JSON(http.StatusInternalServerError, nil)
return
}
ctx.Data["Repos"] = repos
pager := context.NewPagination(int(count), pageSize, page, 3)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
autofocus := ""
if isGet {
switch {
// Token name will be autofocused the first time the page is loaded -- if form.Scope is empty then that would be
// a good sign it's the first load.
case len(getForm.Scope) == 0:
autofocus = "name"
// After submitting a search, refocus the search text box. Search invokes set_page=1 to reset the pagination
// which we'll use to detect this case.
case getForm.SetPage == 1:
autofocus = "search"
}
}
ctx.Data["Autofocus"] = autofocus
}
// Applications render manage access token page
@ -47,18 +212,32 @@ func AccessTokenCreate(ctx *context.Context) {
ctx.Data["PageIsSettingsApplications"] = true
loadAccessTokenCreateData(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplAccessTokenEdit)
}
// ApplicationsPost response for add user's access token
func AccessTokenCreatePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.NewAccessTokenForm)
form := web.GetForm(ctx).(*forms.NewAccessTokenPostForm)
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsApplications"] = true
renderWithError := func(msg template.HTML) {
loadAccessTokenCreateData(ctx)
if ctx.Written() {
return
}
ctx.RenderWithErr(msg, tplAccessTokenEdit, form)
}
if ctx.HasError() {
loadAccessTokenCreateData(ctx)
if ctx.Written() {
return
}
ctx.HTML(http.StatusOK, tplAccessTokenEdit)
return
}
@ -69,32 +248,66 @@ func AccessTokenCreatePost(ctx *context.Context) {
return
}
if !scope.HasPermissionScope() {
loadAccessTokenCreateData(ctx)
ctx.RenderWithErr(ctx.Tr("settings.at_least_one_permission"), tplAccessTokenEdit, form)
renderWithError(ctx.Tr("settings.at_least_one_permission"))
return
}
t := &auth_model.AccessToken{
UID: ctx.Doer.ID,
Name: form.Name,
Scope: scope,
}
// maintain legacy behaviour until new UI options are added -- token has access to all resources, is not
// fine-grained
ResourceAllRepos: true,
var resourceRepos []*auth_model.AccessTokenResourceRepo
switch form.Resource {
case "all":
t.ResourceAllRepos = true
case "public-only":
t.ResourceAllRepos = true
newScopeUnnormalized := fmt.Sprintf("%s,%s", scope, auth_model.AccessTokenScopePublicOnly)
newScope, err := auth_model.AccessTokenScope(newScopeUnnormalized).Normalize()
if err != nil {
ctx.ServerError("AccessTokenScope.Normalize", err)
return
}
t.Scope = newScope
case "repo-specific":
t.ResourceAllRepos = false
selectedRepos := getSelectedRepos(ctx, form.SelectedRepo)
if ctx.Written() {
return
}
for _, repo := range selectedRepos {
resourceRepos = append(resourceRepos, &auth_model.AccessTokenResourceRepo{RepoID: repo.ID})
}
}
exist, err := auth_model.AccessTokenByNameExists(ctx, t)
if err != nil {
ctx.ServerError("AccessTokenByNameExists", err)
return
}
if exist {
loadAccessTokenCreateData(ctx)
ctx.RenderWithErr(ctx.Tr("settings.generate_token_name_duplicate", t.Name), tplAccessTokenEdit, form)
} else if exist {
renderWithError(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
return
}
if err := auth_model.NewAccessToken(ctx, t); err != nil {
if err := authz.ValidateAccessToken(t, resourceRepos); err != nil {
s := user.TranslateAccessTokenValidationError(ctx.Base, err)
if has, str := s.Get(); has {
renderWithError(template.HTML(template.HTMLEscapeString(str)))
return
}
ctx.ServerError("ValidateAccessToken", err)
return
}
err = db.WithTx(ctx, func(ctx stdCtx.Context) error {
if err := auth_model.NewAccessToken(ctx, t); err != nil {
return err
}
return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
})
if err != nil {
ctx.ServerError("NewAccessToken", err)
return
}

View file

@ -0,0 +1,159 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"net/http"
"net/http/httptest"
"slices"
"testing"
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
"forgejo.org/modules/templates"
"forgejo.org/modules/web"
"forgejo.org/services/context"
"forgejo.org/services/contexttest"
"forgejo.org/services/forms"
"code.forgejo.org/go-chi/binding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAccessTokenCreate(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, resp := contexttest.MockContext(t, "user/settings/applications/tokens/new",
contexttest.MockContextOption{Render: templates.HTMLRenderer()})
contexttest.LoadUser(t, ctx, 2)
web.SetForm(ctx, &forms.NewAccessTokenGetForm{})
AccessTokenCreate(ctx)
assert.Equal(t, http.StatusOK, resp.Result().StatusCode)
assert.False(t, ctx.HasError(), "error: %s", ctx.GetErrMsg())
// check empty-form GET ctx.Data values
assert.Equal(t, "name", ctx.Data["Autofocus"])
assert.Len(t, ctx.Data["Repos"], 10)
assert.Empty(t, ctx.Data["SelectedRepos"])
// check that repo in the search is rendered in the content
assert.Contains(t, resp.Body.String(), "org17/big_test_private_4")
}
func TestAccessTokenCreatePost(t *testing.T) {
unittest.PrepareTestEnv(t)
post := func(t *testing.T, form *forms.NewAccessTokenPostForm) (*context.Context, *httptest.ResponseRecorder) {
t.Helper()
ctx, resp := contexttest.MockContext(t, "user/settings/applications/tokens/new",
contexttest.MockContextOption{Render: templates.HTMLRenderer()})
contexttest.LoadUser(t, ctx, 2)
ctx.AppendContextValue(context.WebContextKey, ctx)
binding.Bind(ctx.Req.WithContext(ctx), form)
web.SetForm(ctx, form)
AccessTokenCreatePost(ctx)
return ctx, resp
}
render := func(t *testing.T, form *forms.NewAccessTokenPostForm) (*context.Context, string) {
t.Helper()
ctx, resp := post(t, form)
assert.Equal(t, http.StatusOK, resp.Result().StatusCode)
return ctx, resp.Body.String()
}
t.Run("retains form info on missing token name", func(t *testing.T) {
ctx, body := render(t, &forms.NewAccessTokenPostForm{
Name: "", // absent
Resource: "repo-specific",
Scope: []string{"read:repository"},
SelectedRepo: []string{"org17/big_test_private_4"},
})
require.Contains(t, body, "settings.token_nameform.require_error")
assert.Equal(t, "repo-specific", ctx.Data["resource"])
assert.Contains(t, ctx.Data["scope"], "read:repository")
assert.True(t, slices.ContainsFunc(ctx.Data["SelectedRepos"].([]*repo_model.Repository), func(r *repo_model.Repository) bool {
return r.OwnerName == "org17" && r.Name == "big_test_private_4"
}), "SelectedRepos missing org17/big_test_private_4")
})
t.Run("retains form info on missing scopes", func(t *testing.T) {
ctx, body := render(t, &forms.NewAccessTokenPostForm{
Name: "my new token",
Resource: "repo-specific",
Scope: []string{"", "", ""}, // absent
SelectedRepo: []string{"org17/big_test_private_4"},
})
require.Contains(t, body, "settings.at_least_one_permission")
assert.Equal(t, "my new token", ctx.Data["name"])
assert.Equal(t, "repo-specific", ctx.Data["resource"])
assert.True(t, slices.ContainsFunc(ctx.Data["SelectedRepos"].([]*repo_model.Repository), func(r *repo_model.Repository) bool {
return r.OwnerName == "org17" && r.Name == "big_test_private_4"
}), "SelectedRepos missing org17/big_test_private_4")
})
t.Run("retains form info on duplicate token name", func(t *testing.T) {
ctx, body := render(t, &forms.NewAccessTokenPostForm{
Name: "Token A", // duplicate
Resource: "repo-specific",
Scope: []string{"read:repository"},
SelectedRepo: []string{"org17/big_test_private_4"},
})
require.Contains(t, body, "settings.generate_token_name_duplicate")
assert.Equal(t, "Token A", ctx.Data["name"])
assert.Equal(t, "repo-specific", ctx.Data["resource"])
assert.Contains(t, ctx.Data["scope"], "read:repository")
assert.True(t, slices.ContainsFunc(ctx.Data["SelectedRepos"].([]*repo_model.Repository), func(r *repo_model.Repository) bool {
return r.OwnerName == "org17" && r.Name == "big_test_private_4"
}), "SelectedRepos missing org17/big_test_private_4")
})
t.Run("retains form info on ValidateAccessToken error", func(t *testing.T) {
ctx, body := render(t, &forms.NewAccessTokenPostForm{
Name: "my new token",
Resource: "repo-specific",
Scope: []string{"read:admin"}, // not permitted for repo-specific
SelectedRepo: []string{"org17/big_test_private_4"},
})
require.Contains(t, body, "access_token.error.specified_repos_and_invalid_scope")
assert.Equal(t, "my new token", ctx.Data["name"])
assert.Equal(t, "repo-specific", ctx.Data["resource"])
assert.Contains(t, ctx.Data["scope"], "read:admin")
assert.True(t, slices.ContainsFunc(ctx.Data["SelectedRepos"].([]*repo_model.Repository), func(r *repo_model.Repository) bool {
return r.OwnerName == "org17" && r.Name == "big_test_private_4"
}), "SelectedRepos missing org17/big_test_private_4")
})
t.Run("invalid repo selected", func(t *testing.T) {
_, resp := post(t, &forms.NewAccessTokenPostForm{
Name: "my new token",
Resource: "repo-specific",
Scope: []string{"read:admin"}, // not permitted for repo-specific
SelectedRepo: []string{"org17/big_test_private_4000000_does_not_exist"},
})
assert.Equal(t, http.StatusBadRequest, resp.Result().StatusCode)
assert.Equal(t, "GetRepositoryByOwnerAndName\n", resp.Body.String())
})
t.Run("non-visible repo selected via IDOR", func(t *testing.T) {
_, resp := post(t, &forms.NewAccessTokenPostForm{
Name: "my new token",
Resource: "repo-specific",
Scope: []string{"read:repository"},
SelectedRepo: []string{"user30/empty"}, // private repo, user2 has no visibility
})
assert.Equal(t, http.StatusBadRequest, resp.Result().StatusCode)
assert.Equal(t, "GetRepositoryByOwnerAndName\n", resp.Body.String()) // should be exact same response as "invalid repo selected" case
})
}

View file

@ -635,8 +635,8 @@ func registerRoutes(m *web.Route) {
// access token
m.Group("/tokens", func() {
m.Combo("/new").
Get(user_setting.AccessTokenCreate).
Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.AccessTokenCreatePost)
Get(web.Bind(forms.NewAccessTokenGetForm{}), user_setting.AccessTokenCreate).
Post(web.Bind(forms.NewAccessTokenPostForm{}), user_setting.AccessTokenCreatePost)
m.Post("/delete", user_setting.DeleteAccessToken)
m.Post("/regenerate", user_setting.RegenerateAccessToken)
})

View file

@ -356,19 +356,44 @@ func (f *EditVariableForm) Validate(req *http.Request, errs binding.Errors) bind
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// NewAccessTokenForm form for creating access token
type NewAccessTokenForm struct {
Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"`
Scope []string
// NewAccessTokenGetForm form for creating access token. Similar to NewAccessTokenPostForm, but contains some data that
// is only part of the GET requests as the SelectedRepo field is built up interactively, and, it also removes the
// Required binding to allow this to be used on a formless GET request without displaying an initial error.
type NewAccessTokenGetForm struct {
Name string `binding:"MaxSize(255)" locale:"settings.token_name"`
Resource string // all, public-only, repo-specific
Scope []string
SelectedRepo []string // slice of ownername/reponame
// Transient form values, not part of the final data for the access token form
RepoSearch string
AddSelectedRepo string // add a repo to SelectedRepo
RemoveSelectedRepo string // remove a repo from SelectedRepo
Page int // repo search page
SetPage int // repo search buttons
}
// Validate validates the fields
func (f *NewAccessTokenForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
func (f *NewAccessTokenGetForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
func (f *NewAccessTokenForm) GetScope() (auth_model.AccessTokenScope, error) {
// NewAccessTokenPostForm form for creating access token
type NewAccessTokenPostForm struct {
Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"`
Resource string `binding:"Required" locale:"settings.repo_and_org_access"` // all, public-only, repo-specific
Scope []string
SelectedRepo []string // slice of ownername/reponame
}
// Validate validates the fields
func (f *NewAccessTokenPostForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
func (f *NewAccessTokenPostForm) GetScope() (auth_model.AccessTokenScope, error) {
scope := strings.Join(f.Scope, ",")
s, err := auth_model.AccessTokenScope(scope).Normalize()
return s, err

View file

@ -91,16 +91,16 @@ func TestRegisterForm_IsDomainAllowed_BlockedEmail(t *testing.T) {
func TestNewAccessTokenForm_GetScope(t *testing.T) {
tests := []struct {
form NewAccessTokenForm
form NewAccessTokenPostForm
scope auth_model.AccessTokenScope
expectedErr error
}{
{
form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository"}},
form: NewAccessTokenPostForm{Name: "test", Scope: []string{"read:repository"}},
scope: "read:repository",
},
{
form: NewAccessTokenForm{Name: "test", Scope: []string{"read:repository", "write:user"}},
form: NewAccessTokenPostForm{Name: "test", Scope: []string{"read:repository", "write:user"}},
scope: "read:repository,write:user",
},
}

View file

@ -1,63 +1,180 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings applications")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_access_token"}}
</h4>
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.generate_new_token"}}
</h4>
<div class="ui attached bottom segment">
<form id="scoped-access-form" class="ui form" action="{{.Link}}" method="post">
<h5 class="ui top header">
{{ctx.Locale.Tr "settings.generate_new_token"}}
</h5>
<div class="required field {{if .Err_Name}}error{{end}}">
<label for="name">{{ctx.Locale.Tr "settings.token_name"}}</label>
<input id="name" name="name" value="{{.name}}" autofocus required maxlength="255">
</div>
<div class="field">
<div class="ui attached bottom segment">
<form id="scoped-access-form" class="ui form" action="{{.Link}}" method="post">
<div class="required field {{if .Err_Name}}error{{end}}">
<label for="name">{{ctx.Locale.Tr "settings.token_name"}}</label>
<input id="name" name="name" value="{{.name}}" {{if eq .Autofocus "name"}}autofocus{{end}} required maxlength="255">
</div>
<div class="field">
<fieldset>
<label>{{ctx.Locale.Tr "settings.repo_and_org_access"}}</label>
<label class="tw-cursor-pointer">
<input class="enable-system tw-mt-1 tw-mr-1" type="radio" name="scope" value="{{$.AccessTokenScopePublicOnly}}" {{if (SliceUtils.Contains .scope $.AccessTokenScopePublicOnly)}} checked {{end}}>
{{ctx.Locale.Tr "settings.permissions_public_only"}}
</label>
<label class="tw-cursor-pointer">
<input class="enable-system tw-mt-1 tw-mr-1" type="radio" name="scope" value="" {{if (not (SliceUtils.Contains .scope $.AccessTokenScopePublicOnly))}} checked {{end}}>
{{ctx.Locale.Tr "settings.permissions_access_all"}}
</label>
</div>
<div class="field">
<h5>
{{ctx.Locale.Tr "settings.select_permissions"}}
</h5>
<p class="activity meta">
<p>{{ctx.Locale.Tr "settings.access_token_desc" (printf "%s/api/swagger" AppSubUrl) "https://forgejo.org/docs/latest/user/token-scope/"}}</p>
</p>
{{range .Categories}}
<div class="field tw-pl-1 tw-pb-1 access-token-category">
<label class="category-label" for="access-token-scope-{{.}}">
{{.}}
</label>
<div class="gitea-select">
<select class="ui selection access-token-select" name="scope" id="access-token-scope-{{.}}">
<option value="">
{{ctx.Locale.Tr "settings.permission_no_access"}}
</option>
<option value="read:{{.}}" {{if (SliceUtils.Contains $.scope (printf "read:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_read"}}
</option>
<option value="write:{{.}}" {{if (SliceUtils.Contains $.scope (printf "write:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_write"}}
</option>
</select>
</div>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-all" class="enable-system" type="radio" name="resource" value="all" {{if eq .resource "all"}}checked{{end}}>
<label for="resource-all">{{ctx.Locale.Tr "settings.permissions_access_all"}}</label>
<p class="help">{{ctx.Locale.Tr "settings.access_token.resource_all_help"}}</p>
</div>
{{end}}
</div>
<button id="scoped-access-submit" class="ui primary button">
{{ctx.Locale.Tr "settings.generate_token"}}
</button>
</form>
</div>
</div>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-public-only" class="enable-system" type="radio" name="resource" value="public-only" {{if eq .resource "public-only"}}checked{{end}}>
<label for="resource-public-only">{{ctx.Locale.Tr "settings.permissions_public_only"}}</label>
<p class="help">
{{ctx.Locale.Tr "settings.access_token.resource_public_only_help"}}
{{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}}
</p>
</div>
</div>
<div class="field">
<div class="field ui radio checkbox">
<input id="resource-repo-specific" class="enable-system" type="radio" name="resource" value="repo-specific" {{if eq .resource "repo-specific"}}checked{{end}}>
<label for="resource-repo-specific">{{ctx.Locale.Tr "settings.permissions_access_specific_repositories"}}</label>
<p class="help">
{{ctx.Locale.Tr "settings.access_token.resource_specific_repo_help"}}
{{if $.IsAdmin}}{{ctx.Locale.Tr "settings.access_token.admin_disabled"}}{{end}}
</p>
</div>
</div>
</fieldset>
</div>
<div id="repo-selector-wrapper" role="group" class="field tw-mt-4">
<div id="repo-selector" class="tw-flex tw-flex-wrap tw-gap-8">
<!-- left-hand side: repo list from a search -->
<div class="ui tab active list tw-flex-1">
<h5>
{{ctx.Locale.Tr "settings.access_token.available_repositories"}}
</h5>
<div class="ui small fluid action left icon input tw-mb-3">
<input type="search" name="repo_search" spellcheck="false" {{if eq .Autofocus "search"}}autofocus{{end}} placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.repo_search}}">
<i class="icon">{{svg "octicon-search" 16}}</i>
<button class="ui small icon button" aria-label="{{ctx.Locale.Tr "search.search"}}" type="submit" name="set_page" value="1" formnovalidate="true" formmethod="get">
{{svg "octicon-search" 16}}
</button>
</div>
{{if eq (len .Repos) 0}}
{{ctx.Locale.Tr "settings.access_token.no_repositories_found"}}
{{else}}
<div class="tw-grid tw-items-center" style="grid-template-columns: min-content 1fr min-content;">
{{range .Repos}}
{{template "user/settings/repo_icon" .}}
<div class="text truncate">
{{.FullName}}
</div>
<button class="ui primary button tw-ml-2 tw-my-1 tiny" type="submit" aria-label="{{ctx.Locale.Tr "repo.editor.add" .FullName}}" formnovalidate="true" name="add_selected_repo" value="{{.FullName}}" formmethod="get">
{{ctx.Locale.Tr "add"}}
</button>
{{end}}
</div>
{{end}}
{{/* Can't use base/paginate template include here because all the pagination links in
that template are simple <a href=...> links. Here, we need to turn them into form
buttons so that we can submit the current form. If a user just changed a value (eg. set
the token name, changed a selected permission) and then clicked a pagination button, the
new value that they changed needs to be submitted. base/paginate would allow preserving
old values from before the change, but not new updates. Implementing here also allows
the use of smaller styling. */}}
{{with .Page.Paginater}}
<input type="hidden" name="page" value="{{.Current}}">
<div class="center page buttons">
<div class="ui borderless pagination menu mini">
<button class="item navigation {{if .IsFirst}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="1" formmethod="get">
{{svg "gitea-double-chevron-left" 16 "tw-mr-1"}}
<span class="navigation_label">{{ctx.Locale.Tr "admin.first_page"}}</span>
</button>
<button class="item navigation {{if not .HasPrevious}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Previous}}" formmethod="get">
{{svg "octicon-chevron-left" 16 "tw-mr-1"}}
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.previous"}}</span>
</button>
{{range .Pages}}
{{if eq .Num -1}}
<a class="disabled item">...</a>
{{else}}
<button class="item navigation {{if .IsCurrent}}active{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Num}}" formmethod="get">
{{.Num}}
</button>
{{end}}
{{end}}
<button class="item navigation {{if not .HasNext}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.Next}}" formmethod="get">
<span class="navigation_label">{{ctx.Locale.Tr "repo.issues.next"}}</span>
{{svg "octicon-chevron-right" 16 "tw-ml-1"}}
</button>
<button class="item navigation {{if .IsLast}}disabled{{end}}" type="submit" formnovalidate="true" name="set_page" value="{{.TotalPages}}" formmethod="get">
<span class="navigation_label">{{ctx.Locale.Tr "admin.last_page"}}</span>
{{svg "gitea-double-chevron-right" 16 "tw-ml-1"}}
</button>
</div>
</div>
{{end}}
</div>
<!-- right-hand side: selected repositories -->
<div class="tw-flex-1">
<h5>{{ctx.Locale.TrPluralString (len .SelectedRepos) "settings.access_token.selected_repositories" (len .SelectedRepos)}}</h5>
{{if eq (len .SelectedRepos) 0}}
<div class="tw-my-2">
{{ctx.Locale.Tr "settings.access_token.no_repositories_selected"}}
</div>
{{else}}
<div class="tw-grid tw-items-center tw-auto-rows-min" style="grid-template-columns: min-content 1fr min-content;">
{{range .SelectedRepos}}
<input type="hidden" name="selected_repo" value="{{.FullName}}">
{{template "user/settings/repo_icon" .}}
<div class="text tw-text-nowrap"> <!-- nowrap, !truncate, ensures that this can be fully seen and ensures this side of the control has an appropriate min-width -->
{{.FullName}}
</div>
<button class="ui button tw-ml-2 tw-my-1 tiny" type="submit" aria-label="{{ctx.Locale.Tr "settings.access_token.remove" .FullName}}" formnovalidate="true" name="remove_selected_repo" value="{{.FullName}}" formmethod="get">
{{ctx.Locale.Tr "remove"}}
</button>
{{end}}
</div>
{{end}}
</div>
</div>
</div>
<div class="field">
<label>
{{ctx.Locale.Tr "settings.select_permissions"}}
</label>
<p class="activity meta">
<p>{{ctx.Locale.Tr "settings.access_token_desc" (printf "%s/api/swagger" AppSubUrl) "https://forgejo.org/docs/latest/user/token-scope/"}}</p>
</p>
{{range .Categories}}
<div class="field tw-pl-1 tw-pb-1 access-token-category">
<label class="category-label" for="access-token-scope-{{.}}">
{{.}}
</label>
<div class="gitea-select">
<select class="ui selection access-token-select" name="scope" id="access-token-scope-{{.}}">
<option value="">
{{ctx.Locale.Tr "settings.permission_no_access"}}
</option>
<option value="read:{{.}}" {{if (SliceUtils.Contains $.scope (printf "read:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_read"}}
</option>
<option value="write:{{.}}" {{if (SliceUtils.Contains $.scope (printf "write:%s" .))}} selected {{end}}>
{{ctx.Locale.Tr "settings.permission_write"}}
</option>
</select>
</div>
</div>
{{end}}
</div>
<button id="scoped-access-submit" class="ui primary button">
{{ctx.Locale.Tr "settings.generate_token"}}
</button>
</form>
</div>
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -0,0 +1,9 @@
{{$repoIcon := "octicon-repo"}}
{{if .IsFork}}
{{$repoIcon = "octicon-repo-forked"}}
{{else if .IsMirror}}
{{$repoIcon = "octicon-mirror"}}
{{else if .IsPrivate}}
{{$repoIcon = "octicon-lock"}}
{{end}}
{{svg $repoIcon 16 "repo-list-icon tw-mr-2"}}

View file

@ -141,8 +141,12 @@ test('User: Add access token', async ({browser}, workerInfo) => {
const tokenName = globalThis.crypto.randomUUID();
await page.locator('#name').fill(tokenName);
await page.getByRole('radio', {name: /^All /}).click();
await page.locator('#scoped-access-submit').click();
await expect(page.locator('.ui.info.message.flash-info')).toBeVisible();
const flashText = await page.locator('.ui.info.message.flash-info').textContent();
expect(flashText?.trim()).toMatch(/^[0-9a-f]{40}$/);
await page.getByText(tokenName).isVisible();
});
@ -161,7 +165,75 @@ test('User: Add access token validation error', async ({browser}, workerInfo) =>
await page.getByRole('button', {name: 'Generate token'}).click();
await page.getByText('has been used as an application name already.').isVisible();
// validate that selected options (public-only, activitypub) are still selected.
// validate that selected options (public-only, activitypub) are still selected after the validation error.
await expect(page.getByRole('radio', {name: 'Public only'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'activitypub'})).toHaveValue('read:activitypub');
});
test('User: Add specific repo access token', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
const tokenName = globalThis.crypto.randomUUID();
await page.getByRole('textbox', {name: /^Token name/}).fill(tokenName);
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
// clicking specific repositories will display currently available repositories:
await expect(page.getByText('org17/big_test_private_4')).toBeHidden();
await page.getByRole('radio', {name: 'Specific repositories'}).click();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeVisible(); // another repo, will be used to verify search worked
await page.getByPlaceholder('Search repos…').fill('big_test_private_4');
await page.getByRole('button', {name: 'Search…'}).click();
// verify search results visible:
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeHidden();
// after performing a search, verify that the token name, 'selected repositories', and selected permissions are maintained
await expect(page.getByRole('textbox', {name: /^Token name/})).toHaveValue(tokenName);
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
// Add the big_test_private_4 repo.
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repository (1)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
// Remove it to test remove, and then re-add
await page.getByRole('button', {name: 'Remove org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repositories (0)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
// Create the token and check for success.
await page.getByRole('button', {name: 'Generate token'}).click();
await expect(page.locator('.ui.info.message.flash-info')).toBeVisible();
const flashText = await page.locator('.ui.info.message.flash-info').textContent();
expect(flashText?.trim()).toMatch(/^[0-9a-f]{40}$/);
await page.getByText(tokenName).isVisible();
});
// Test that validation errors on the repo-specific access token page retain all the entered field values when the
// error is displayed.
test('User: Add specific repo access token error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
await page.getByRole('textbox', {name: /^Token name/}).fill('Token A');
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
await page.getByRole('radio', {name: 'Specific repositories'}).click();
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
// Create the token, verify error, then check all the fields for retained values.
await page.getByRole('button', {name: 'Generate token'}).click();
await page.getByText('has been used as an application name already.').isVisible();
await expect(page.getByRole('textbox', {name: /^Token name/})).toHaveValue('Token A');
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
await expect(page.getByRole('button', {name: 'Remove org17/big_test_private_4'})).toBeVisible();
});

View file

@ -491,8 +491,18 @@ func createApplicationSettingsToken(t testing.TB, session *TestSession, name str
urlValues := url.Values{}
urlValues.Add("name", name)
publicOnly := false
for _, scope := range scopes {
urlValues.Add("scope", string(scope))
if scope == auth.AccessTokenScopePublicOnly {
publicOnly = true
} else {
urlValues.Add("scope", string(scope))
}
}
if publicOnly {
urlValues.Add("resource", "public-only")
} else {
urlValues.Add("resource", "all")
}
req := NewRequestWithURLValues(t, "POST", "/user/settings/applications/tokens/new", urlValues)
resp := session.MakeRequest(t, req, http.StatusSeeOther)

View file

@ -141,3 +141,30 @@
.notifications-item:hover .notifications-updated {
display: none;
}
/* user's access token edit page; /user/settings/applications/tokens/new; custom CSS for repo selector visibility */
/* #repo-selector-wrapper hides and shows the repository selection based upon the radio-button value, with an animation
to hint the connection between the two to the user. */
#repo-selector-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-out;
}
body:has(input[name="resource"][value="repo-specific"]:checked) #repo-selector-wrapper {
grid-template-rows: 1fr;
transition: grid-template-rows 0.3s ease-in;
}
/* Accessibility -- remove from the visibility tree when hidden so that it doesn't appear for screenreaders. Downside of
this is that the contents of the selector disappear during the ease-in animation, but it's barely noticable. The
expansion animation is the important one anyway, as it associates the selector box with the selection just made for the
user. */
#repo-selector {
overflow: hidden; /* needed so that the grid-template-rows=0fr clips the selector out */
visibility: hidden;
}
body:has(input[name="resource"][value="repo-specific"]:checked) #repo-selector {
visibility: visible;
}