feat: add ability to edit API token scopes #699

Merged
jmiller merged 2 commits from feature/edit-token-scopes into main 2026-06-25 15:00:25 +00:00
8 changed files with 250 additions and 1 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697)
- Edit API token scopes: PATCH /users/{username}/tokens/{id} API endpoint + web UI edit button (#697)
- Wiki full-text search: case-insensitive search across all wiki page titles and content (#550)
- Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550)
- Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692)
+10
View File
@@ -40,6 +40,16 @@ type CreateAccessTokenOption struct {
Scopes []string `json:"scopes"`
}
// EditAccessTokenOption options when editing access token scopes
// swagger:model EditAccessTokenOption
type EditAccessTokenOption struct {
// The new name for the token (optional)
Name string `json:"name"`
// The new scopes for the token
// example: ["read:repository", "write:issue"]
Scopes []string `json:"scopes"`
}
// CreateOAuth2ApplicationOptions holds options to create an oauth2 application
type CreateOAuth2ApplicationOptions struct {
// The name of the OAuth2 application
+2
View File
@@ -855,6 +855,8 @@
"settings.access_token_deletion_confirm_action": "Delete",
"settings.access_token_deletion_desc": "Deleting a token will revoke access to your account for applications using it. This cannot be undone. Continue?",
"settings.delete_token_success": "The token has been deleted. Applications using it no longer have access to your account.",
"settings.edit_token_scopes": "Edit Token Scopes",
"settings.update_token_success": "Token scopes have been updated successfully.",
"settings.repo_and_org_access": "Repository and Organization Access",
"settings.permissions_public_only": "Public only",
"settings.permissions_access_all": "All (public, private, and limited)",
+3 -1
View File
@@ -1007,7 +1007,9 @@ func Routes() *web.Router {
m.Group("/tokens", func() {
m.Combo("").Get(user.ListAccessTokens).
Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken)
m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken)
m.Combo("/{id}").
Patch(bind(api.EditAccessTokenOption{}), reqToken(), user.UpdateAccessToken).
Delete(reqToken(), user.DeleteAccessToken)
}, reqSelfOrAdmin(), reqBasicOrRevProxyAuth())
m.Get("/activities/feeds", user.ListUserActivityFeeds)
+100
View File
@@ -209,6 +209,106 @@ func DeleteAccessToken(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
// UpdateAccessToken update access token scopes
func UpdateAccessToken(ctx *context.APIContext) {
// swagger:operation PATCH /users/{username}/tokens/{id} user userUpdateAccessToken
// ---
// summary: Update an access token's scopes
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user whose token is to be updated
// type: string
// required: true
// - name: id
// in: path
// description: id of the token to update
// type: integer
// format: int64
// required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/EditAccessTokenOption"
// responses:
// "200":
// "$ref": "#/responses/AccessToken"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
// "$ref": "#/responses/notFound"
tokenID, _ := strconv.ParseInt(ctx.PathParam("id"), 0, 64)
if tokenID == 0 {
ctx.APIErrorNotFound()
return
}
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{
UserID: ctx.ContextUser.ID,
})
if err != nil {
ctx.APIErrorInternal(err)
return
}
var token *auth_model.AccessToken
for _, t := range tokens {
if t.ID == tokenID {
token = t
break
}
}
if token == nil {
ctx.APIErrorNotFound()
return
}
form := web.GetForm(ctx).(*api.EditAccessTokenOption)
if form.Name == "" && len(form.Scopes) == 0 {
ctx.APIError(http.StatusBadRequest, "must provide name or scopes to update")
return
}
if form.Name != "" {
token.Name = form.Name
}
if len(form.Scopes) > 0 {
scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
if err != nil {
ctx.APIError(http.StatusBadRequest, fmt.Errorf("invalid access token scope: %w", err))
return
}
if scope == "" {
ctx.APIError(http.StatusBadRequest, "access token must have a scope")
return
}
token.Scope = scope
}
if err := auth_model.UpdateAccessToken(ctx, token); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, &api.AccessToken{
ID: token.ID,
Name: token.Name,
TokenLastEight: token.TokenLastEight,
Scopes: token.Scope.StringSlice(),
Created: token.CreatedUnix.AsTime(),
Updated: token.UpdatedUnix.AsTime(),
})
}
// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user
func CreateOauth2Application(ctx *context.APIContext) {
// swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application
+53
View File
@@ -90,6 +90,59 @@ func ApplicationsPost(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
}
// EditApplication response for editing user access token scopes
func EditApplication(ctx *context.Context) {
tokenID := ctx.FormInt64("id")
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
if err != nil {
ctx.ServerError("ListAccessTokens", err)
return
}
var token *auth_model.AccessToken
for _, t := range tokens {
if t.ID == tokenID {
token = t
break
}
}
if token == nil {
ctx.Flash.Error("Token not found")
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
return
}
_ = ctx.Req.ParseForm()
var scopeNames []string
const accessTokenScopePrefix = "scope-"
for k, v := range ctx.Req.Form {
if strings.HasPrefix(k, accessTokenScopePrefix) {
scopeNames = append(scopeNames, v...)
}
}
scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize()
if err != nil {
ctx.ServerError("GetScope", err)
return
}
if !scope.HasPermissionScope() {
ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"))
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
return
}
token.Scope = scope
if err := auth_model.UpdateAccessToken(ctx, token); err != nil {
ctx.ServerError("UpdateAccessToken", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.update_token_success"))
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
}
// DeleteApplication response for delete user access token
func DeleteApplication(ctx *context.Context) {
if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
+1
View File
@@ -680,6 +680,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// access token applications
m.Combo("").Get(user_setting.Applications).
Post(web.Bind(forms.NewAccessTokenForm{}), user_setting.ApplicationsPost)
m.Post("/edit", user_setting.EditApplication)
m.Post("/delete", user_setting.DeleteApplication)
})
+80
View File
@@ -40,6 +40,10 @@
</div>
</div>
<div class="item-trailing">
<button class="ui primary tiny button edit-token-button" data-modal-id="edit-token" data-id="{{.ID}}" data-scopes="{{StringUtils.Join (.Scope.StringSlice) ","}}">
{{svg "octicon-pencil"}}
{{ctx.Locale.Tr "edit"}}
</button>
<button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
{{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}}
@@ -92,6 +96,82 @@
{{end}}
</div>
<div class="ui modal" id="edit-token">
<div class="header">
{{svg "octicon-pencil"}}
{{ctx.Locale.Tr "settings.edit_token_scopes"}}
</div>
<div class="content">
<form class="ui form" id="edit-token-form" action="{{.Link}}/edit" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="id" value="">
<div class="field">
<div class="tw-my-2">{{ctx.Locale.Tr "settings.repo_and_org_access"}}</div>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="{{$.AccessTokenScopePublicOnly}}"> {{ctx.Locale.Tr "settings.permissions_public_only"}}
</label>
<label class="gt-checkbox">
<input type="radio" name="scope-public-only" value="" checked> {{ctx.Locale.Tr "settings.permissions_access_all"}}
</label>
</div>
<div>
<div class="tw-my-2">{{ctx.Locale.Tr "settings.permissions_list"}}</div>
<table class="ui table unstackable tw-my-2">
{{range $category := .TokenCategories}}
<tr>
<td>{{$category}}</td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="" checked> {{ctx.Locale.Tr "settings.permission_no_access"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="read:{{$category}}"> {{ctx.Locale.Tr "settings.permission_read"}}</label></td>
<td><label class="gt-checkbox"><input type="radio" name="scope-{{$category}}" value="write:{{$category}}"> {{ctx.Locale.Tr "settings.permission_write"}}</label></td>
</tr>
{{end}}
</table>
</div>
</form>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" id="edit-token-submit">{{ctx.Locale.Tr "save"}}</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
for (const btn of document.querySelectorAll('.edit-token-button')) {
btn.addEventListener('click', (e) => {
const modal = document.getElementById('edit-token');
const form = document.getElementById('edit-token-form');
const id = e.currentTarget.getAttribute('data-id');
const scopes = (e.currentTarget.getAttribute('data-scopes') || '').split(',').filter(Boolean);
form.querySelector('input[name="id"]').value = id;
// Reset all radios to defaults
for (const radio of form.querySelectorAll('input[type="radio"]')) {
radio.checked = radio.value === '';
}
// Set current scopes
for (const scope of scopes) {
if (scope === 'public-only') {
const radio = form.querySelector('input[name="scope-public-only"][value="public-only"]');
if (radio) radio.checked = true;
} else {
const radio = form.querySelector(`input[name="scope-${scope.split(':')[1]}"][value="${scope}"]`);
if (radio) radio.checked = true;
}
}
$(modal).modal('show');
});
}
document.getElementById('edit-token-submit')?.addEventListener('click', () => {
document.getElementById('edit-token-form')?.submit();
});
});
</script>
<div class="ui g-modal-confirm delete modal" id="delete-token">
<div class="header">
{{svg "octicon-trash"}}