feat: add ability to edit API token scopes #699
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
Reference in New Issue
Block a user