mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-28 11:14:54 -04:00
feat: add "Forgejo Actions (Local)" authorized integration UI (#12672)
Extracts the separate concepts for different UIs out of the original implementation, and then adds the new UI for Forgejo Actions (Local). Manual end-to-end testing was performed on all variations of the "workflow file", "git reference", and "event" filter options as well. They're covered by test automation, but not in an end-to-end manner. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). 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 - 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)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [x] I did not document these changes and I do not expect someone else to do it. - Documentation is next up after this change is complete. ### 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/12672 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
parent
03d336de44
commit
6d522ecba0
15 changed files with 1142 additions and 184 deletions
|
|
@ -191,6 +191,8 @@ type SearchRepoOptions struct {
|
|||
// Retrieve multiple repositories by their owner name & repository name, similar to [GetRepositoryByOwnerAndName]
|
||||
// but in bulk.
|
||||
OwnerAndName [][2]string
|
||||
// Filter to repositories with this unit enabled.
|
||||
EnabledUnit optional.Option[unit.Type]
|
||||
}
|
||||
|
||||
// UserOwnedRepoCond returns user ownered repositories
|
||||
|
|
@ -518,6 +520,10 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond {
|
|||
}
|
||||
}
|
||||
|
||||
if has, unit := opts.EnabledUnit.Get(); has {
|
||||
cond = cond.And(builder.In("`repository`.id", builder.Select("`repo_unit`.repo_id").From("repo_unit").Where(builder.Eq{"`repo_unit`.type": unit})))
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
|
||||
"forgejo.org/models/db"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unit"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/optional"
|
||||
|
|
@ -199,6 +200,11 @@ func getTestCases() []struct {
|
|||
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, OwnerAndName: [][2]string{}},
|
||||
count: 0,
|
||||
},
|
||||
{
|
||||
name: "ActionsEnabled",
|
||||
opts: &repo_model.SearchRepoOptions{ListOptions: db.ListOptions{Page: 1, PageSize: 10}, EnabledUnit: optional.Some(unit.TypeActions)},
|
||||
count: 3,
|
||||
},
|
||||
}
|
||||
|
||||
return testCases
|
||||
|
|
|
|||
|
|
@ -348,10 +348,21 @@
|
|||
"settings.authorized_integration.specified_repos_and_public_only": "Authorized integrations with specified repositories cannot be combined with the public-only scope.",
|
||||
"settings.authorized_integration.specified_repos_and_invalid_scope": "Authorized integrations with specified repositories can only be used with the read:issue, write:issue, read:repository, and write:repository scopes.",
|
||||
"settings.authorized_integration.add": "Add authorized integration",
|
||||
"settings.authorized_integration.generic": "Generic JWT Source",
|
||||
"settings.authorized_integration.delete.header": "Delete authorized integration",
|
||||
"settings.authorized_integration.delete.body": "Deleting an authorized integration will revoke access to your account for the integrating application. This is permanent, and cannot be undone. Creating a new authorized integration will not have the same Audience (<code>aud</code>) claim. Continue?",
|
||||
"settings.authorized_integration.deleted": "Authorized integration has been deleted successfully.",
|
||||
"settings.authorized_integration.forgejo_actions_local.select_repo": "Select",
|
||||
"settings.authorized_integration.forgejo_actions_local.select_repository": "Select repository: (Forgejo Actions must be enabled on repository)",
|
||||
"settings.authorized_integration.forgejo_actions_local.description": "Forgejo Actions will be able to access Forgejo via this Authorized Integration from a single selected repository, and if the execution meets conditions defined below.",
|
||||
"settings.authorized_integration.forgejo_actions_local.repo.required": "Forgejo Actions source repository must be selected.",
|
||||
"settings.authorized_integration.forgejo_actions_local.workflow_file.label": "Workflow file (without directory):",
|
||||
"settings.authorized_integration.forgejo_actions_local.workflow_file.help": "If empty, any workflow will be permitted. See <a href=\"%[1]s\">%[2]s</a> documentation for pattern syntax.<br>Examples: <code>testing.yml</code>, <code>test-*.yml</code>.",
|
||||
"settings.authorized_integration.forgejo_actions_local.workflow_file.error": "Error parsing workflow file: %s",
|
||||
"settings.authorized_integration.forgejo_actions_local.git_ref.label": "Git reference:",
|
||||
"settings.authorized_integration.forgejo_actions_local.git_ref.help": "If empty, any reference will be permitted. See <a href=\"%[1]s\">%[2]s</a> documentation for pattern syntax.<br>Examples: <code>refs/heads/main</code>, <code>refs/pull/*/head</code>.",
|
||||
"settings.authorized_integration.forgejo_actions_local.git_ref.error": "Error parsing git reference: %s",
|
||||
"settings.authorized_integration.forgejo_actions_local.event.label": "Event:",
|
||||
"settings.authorized_integration.forgejo_actions_local.event.help": "If no events are selected, any event will be permitted.",
|
||||
"webauthn.insert_key": "Insert your security key",
|
||||
"webauthn.sign_in": "Press the button on your security key. If your security key has no button, re-insert it.",
|
||||
"webauthn.press_button": "Please press the button on your security key…",
|
||||
|
|
|
|||
|
|
@ -4,37 +4,114 @@
|
|||
package setting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/json"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/templates"
|
||||
"forgejo.org/modules/util"
|
||||
"forgejo.org/modules/web"
|
||||
"forgejo.org/modules/web/middleware"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/authz"
|
||||
"forgejo.org/services/context"
|
||||
|
||||
"code.forgejo.org/go-chi/binding"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
const (
|
||||
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
|
||||
tplAuthorizedIntegrationViewGeneric base.TplName = "user/settings/authorized_integrations/generic/view"
|
||||
tplAuthorizedIntegrationList base.TplName = "user/settings/authorized_integrations"
|
||||
)
|
||||
|
||||
var authorizedIntegrationUIs = []authorizedIntegrationUIImpl{
|
||||
actionsLocalUI{},
|
||||
genericUI{},
|
||||
}
|
||||
|
||||
// Encapsulates the implementation of each authorized integration's UI.
|
||||
type authorizedIntegrationUIImpl interface {
|
||||
// The URL path, and value of the UI field in the authorized integration.
|
||||
UIIdentifier() auth_model.AuthorizedIntegrationUI
|
||||
// When rendered in the "Add authorized integration" list, the Icon to use.
|
||||
Icon(size int) template.HTML
|
||||
// When rendered in the "Add authorized integration" list, the Label to use.
|
||||
Label(ctx *templates.Context) template.HTML
|
||||
// HTML template used when rendering this UI.
|
||||
editTemplate() base.TplName
|
||||
// When rendering editTemplate, populateTemplateContext will be invoked to allow the UI to perform backend data
|
||||
// fetches as needed to populate `ctx.Data`. The current form will be available as `ctx.Data["Form"]`.
|
||||
populateTemplateContext(ctx *context.Context)
|
||||
// Form object used when rendering and processing this UI.
|
||||
form() authorizedIntegrationUIForm
|
||||
// If an error occurs, typically in [convertForm] when evaluating if the inputs provided by the user are sufficient
|
||||
// to create claim rules, populateError will be invoked. If it returns [true] then the [editTemplate] will
|
||||
// re-render with the updated context, allowing each UI to handle form errors and display validation results to the
|
||||
// user. If it returns false this indicates the error isn't recognized by the UI, and the error will be inspected
|
||||
// by the base form processing and may result in a form error or a server error.
|
||||
populateError(ctx *context.Context, err error) (handled bool)
|
||||
}
|
||||
|
||||
// Contains all the form data going to the browser when loading an authorized integration, and returning to the server
|
||||
// when validating and saving an authorized integration.
|
||||
//
|
||||
// Every UI-specific form must embed a [baseAuthorizedIntegrationForm] which contains information common to every
|
||||
// authorized integration -- identifying information and permission information. Forms must not conflict on field name
|
||||
// binding with the fields in [baseAuthorizedIntegrationForm].
|
||||
type authorizedIntegrationUIForm interface {
|
||||
// Check whether the form is empty. When loading the new & edit pages, this is used to identify if the form data
|
||||
// needs to be loaded from the database objects or initialized for a new create.
|
||||
isEmpty() bool
|
||||
// Access the embedded baseAuthorizedIntegrationForm
|
||||
baseForm() *baseAuthorizedIntegrationForm
|
||||
// Populate the form from a persisted state, given the stored authorized integration's issuer & claim rules.
|
||||
populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error
|
||||
// Convert the form into the issuer and claim rules that will be saved with the authorized integration.
|
||||
convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error)
|
||||
// Initialize the form with values appropriate for a new authorized integration.
|
||||
initNew()
|
||||
}
|
||||
|
||||
// Middleware that resolves the "ui" parameter into ctx.Data. Access the resolved UI interface via [authorizedIntegrationUI].
|
||||
func BindAuthorizedIntegrationUI(ctx *context.Context) {
|
||||
var ui authorizedIntegrationUIImpl
|
||||
uiString := ctx.Params(":ui")
|
||||
for _, check := range authorizedIntegrationUIs {
|
||||
if auth_model.AuthorizedIntegrationUI(uiString) == check.UIIdentifier() {
|
||||
ui = check
|
||||
break
|
||||
}
|
||||
}
|
||||
if ui == nil {
|
||||
ctx.NotFound("invalid UI", fmt.Errorf("invalid UI: %q is not a supported Authorized Integration UI", uiString))
|
||||
return
|
||||
}
|
||||
ctx.Data["AuthorizedIntegrationUI"] = ui
|
||||
}
|
||||
|
||||
func authorizedIntegrationUI(ctx *context.Context) authorizedIntegrationUIImpl {
|
||||
return ctx.Data["AuthorizedIntegrationUI"].(authorizedIntegrationUIImpl)
|
||||
}
|
||||
|
||||
// Middleware that acts like `web.Bind`, but uses the authorized integration UI's to work with a specific form type
|
||||
// defined by the [AuthorizedIntegrationUI].
|
||||
func DynamicBindAuthorizedIntegrationForm(ctx *context.Context) {
|
||||
ui := authorizedIntegrationUI(ctx)
|
||||
formObj := ui.form()
|
||||
data := middleware.GetContextData(ctx)
|
||||
binding.Bind(ctx.Req, formObj)
|
||||
web.SetForm(data, formObj)
|
||||
}
|
||||
|
||||
func ListAuthorizedIntegrations(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
|
||||
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
|
||||
|
|
@ -46,39 +123,11 @@ func ListAuthorizedIntegrations(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
ctx.Data["AuthorizedIntegrations"] = ais
|
||||
ctx.Data["UIs"] = authorizedIntegrationUIs
|
||||
|
||||
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationList)
|
||||
}
|
||||
|
||||
type AuthorizedIntegrationForm struct {
|
||||
// Top data in UI, descriptive information about the Authorized Integration:
|
||||
Name string
|
||||
Description string
|
||||
Audience string
|
||||
|
||||
// Middle data in UI, how JWTs are validated by this Authorized Integration:
|
||||
Issuer string // Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
|
||||
ClaimRules string // Future: ClaimRules is only required when aiUI == "generic"
|
||||
|
||||
// Bottom data in the UI, what authorization is permitted by this Authorized Integration:
|
||||
Resource string // all, public-only, repo-specific
|
||||
SelectedRepo []string // slice of ownername/reponame for repo-specific
|
||||
ScopeAll bool
|
||||
Scope []string
|
||||
|
||||
// Values used for repo-specific repository multi-select UI, not stored in Authorized Integration:
|
||||
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
|
||||
}
|
||||
|
||||
func (f *AuthorizedIntegrationForm) isEmpty() bool {
|
||||
return f.Name == "" && f.Description == "" && f.Audience == "" && f.Issuer == "" &&
|
||||
f.ClaimRules == "" && f.Resource == "" && f.SelectedRepo == nil && f.Scope == nil
|
||||
}
|
||||
|
||||
func getAuthorizedIntegration(ctx *context.Context) *auth_model.AuthorizedIntegration {
|
||||
aiUIString := ctx.Params("ui")
|
||||
aiUI, err := auth_model.ParseAuthorizedIntegrationUI(aiUIString)
|
||||
|
|
@ -106,7 +155,7 @@ func EditAuthorizedIntegration(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
|
||||
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
|
||||
if form.isEmpty() {
|
||||
repos, err := auth_model.GetRepositoriesAccessibleWithIntegration(ctx, ai.ID)
|
||||
if err != nil {
|
||||
|
|
@ -114,11 +163,16 @@ func EditAuthorizedIntegration(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
form, err = copyAuthorizedIntegrationToForm(ctx, ai, repos)
|
||||
err = form.populateForm(ctx, ai.Issuer, ai.ClaimRules)
|
||||
if err != nil {
|
||||
ctx.ServerError("copyAuthorizedIntegrationToForm", err)
|
||||
return
|
||||
}
|
||||
err = form.baseForm().copyAuthorizedIntegrationToForm(ctx, ai, repos)
|
||||
if err != nil {
|
||||
ctx.ServerError("BaseForm().copyAuthorizedIntegrationToForm", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Data["Form"] = form
|
||||
|
||||
|
|
@ -126,7 +180,7 @@ func EditAuthorizedIntegration(ctx *context.Context) {
|
|||
}
|
||||
|
||||
func EditAuthorizedIntegrationPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
|
||||
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
|
||||
ctx.Data["Form"] = form // make form available for template render on any error
|
||||
|
||||
ai := getAuthorizedIntegration(ctx)
|
||||
|
|
@ -134,7 +188,15 @@ func EditAuthorizedIntegrationPost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
|
||||
issuer, claimRules, err := form.convertForm(ctx)
|
||||
if err != nil {
|
||||
editAuthorizedIntegrationErrorHandler(ctx, err)
|
||||
return
|
||||
}
|
||||
ai.Issuer = issuer
|
||||
ai.ClaimRules = claimRules
|
||||
|
||||
rr, err := form.baseForm().copyFormToAuthorizedIntegration(ctx, ai)
|
||||
if err != nil {
|
||||
editAuthorizedIntegrationErrorHandler(ctx, err)
|
||||
return
|
||||
|
|
@ -149,10 +211,10 @@ func EditAuthorizedIntegrationPost(ctx *context.Context) {
|
|||
}
|
||||
|
||||
func NewAuthorizedIntegration(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
|
||||
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
|
||||
if form.isEmpty() {
|
||||
form.Resource = "all"
|
||||
form.ClaimRules = string("{\n \"rules\":[]\n}")
|
||||
form.initNew()
|
||||
form.baseForm().InitNew()
|
||||
}
|
||||
ctx.Data["Form"] = form
|
||||
ctx.Data["IsNew"] = true
|
||||
|
|
@ -161,15 +223,24 @@ func NewAuthorizedIntegration(ctx *context.Context) {
|
|||
}
|
||||
|
||||
func NewAuthorizedIntegrationPost(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
|
||||
form := web.GetForm(ctx).(authorizedIntegrationUIForm)
|
||||
ctx.Data["Form"] = form // make form available for template render on any error
|
||||
ctx.Data["IsNew"] = true
|
||||
|
||||
ai := &auth_model.AuthorizedIntegration{
|
||||
UserID: ctx.Doer.ID,
|
||||
UI: auth_model.AuthorizedIntegrationUIGeneric,
|
||||
UI: authorizedIntegrationUI(ctx).UIIdentifier(),
|
||||
}
|
||||
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
|
||||
|
||||
issuer, claimRules, err := form.convertForm(ctx)
|
||||
if err != nil {
|
||||
editAuthorizedIntegrationErrorHandler(ctx, err)
|
||||
return
|
||||
}
|
||||
ai.Issuer = issuer
|
||||
ai.ClaimRules = claimRules
|
||||
|
||||
rr, err := form.baseForm().copyFormToAuthorizedIntegration(ctx, ai)
|
||||
if err != nil {
|
||||
editAuthorizedIntegrationErrorHandler(ctx, err)
|
||||
return
|
||||
|
|
@ -181,10 +252,19 @@ func NewAuthorizedIntegrationPost(ctx *context.Context) {
|
|||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.authorized_integration.create_success", ai.Name))
|
||||
ctx.Redirect(setting.AppSubURL + fmt.Sprintf("/user/settings/authorized-integrations/generic/%d", ai.ID))
|
||||
ctx.Redirect(setting.AppSubURL + fmt.Sprintf("/user/settings/authorized-integrations/%s/%d", authorizedIntegrationUI(ctx).UIIdentifier(), ai.ID))
|
||||
}
|
||||
|
||||
func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
|
||||
if authorizedIntegrationUI(ctx).populateError(ctx, err) {
|
||||
EditAuthorizedIntegrationRenderCommon(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: auth_service.ErrInvalidClaimRules is not handled here. If a UI created claim rules that the auth service
|
||||
// identified as invalid, it indicates a logic bug or unhandled case in the UI implementation, and will be treated
|
||||
// as a server error because the base implementation here doesn't know what UI fields would be responsible for these
|
||||
// invalid claim rules. (Excepting the Generic UI, which handles this case itself.)
|
||||
var errMissingField *auth_service.MissingFieldError
|
||||
switch {
|
||||
case errors.As(err, &errMissingField):
|
||||
|
|
@ -200,7 +280,11 @@ func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
|
|||
EditAuthorizedIntegrationRenderCommon(ctx)
|
||||
return
|
||||
case errors.Is(err, auth_service.ErrInvalidIssuer):
|
||||
ctx.Data["Err_Issuer"] = true
|
||||
// ErrInvalidIssuer is a little awkward if it reaches here. Most UIs should prevent the user from entering
|
||||
// something that would cause auth service to find the OIDC issuer to be invalid, but if we've reached here that
|
||||
// hasn't happened. Validating the issuer performs remote HTTP calls so it's possible that this is a transient
|
||||
// error, or the state of the remote has changed (used to be valid, isn't currently). We'll flash the error
|
||||
// here as it might be useful to the user but we don't know how to highlight the relevant fields for the error.
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.issuer.invalid", err.Error()), true)
|
||||
EditAuthorizedIntegrationRenderCommon(ctx)
|
||||
return
|
||||
|
|
@ -230,125 +314,6 @@ func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
|
|||
ctx.ServerError("UpdateAuthorizedIntegration", err)
|
||||
}
|
||||
|
||||
func copyAuthorizedIntegrationToForm(ctx *context.Context, ai *auth_model.AuthorizedIntegration, rr []*auth_model.AuthorizedIntegResourceRepo) (*AuthorizedIntegrationForm, error) {
|
||||
form := &AuthorizedIntegrationForm{
|
||||
Name: ai.Name,
|
||||
Description: ai.Description,
|
||||
Audience: ai.Audience,
|
||||
Issuer: ai.Issuer, // Future: Issuer is only required when ai.UI == "generic"
|
||||
}
|
||||
|
||||
if ai.ResourceAllRepos {
|
||||
publicOnly, err := ai.Scope.PublicOnly()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if publicOnly {
|
||||
form.Resource = "public-only"
|
||||
} else {
|
||||
form.Resource = "all"
|
||||
}
|
||||
} else {
|
||||
form.Resource = "repo-specific"
|
||||
}
|
||||
|
||||
form.Scope = ai.Scope.StringSlice()
|
||||
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form.ScopeAll = scopeAll
|
||||
|
||||
// Future: ClaimRules is only required when aiUI == "generic"
|
||||
claimRulesJSON, err := json.MarshalIndent(ai.ClaimRules, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form.ClaimRules = string(claimRulesJSON)
|
||||
|
||||
form.SelectedRepo = []string{}
|
||||
if len(rr) != 0 {
|
||||
repoIDs := make([]int64, len(rr))
|
||||
for _, r := range rr {
|
||||
repoIDs = append(repoIDs, r.RepoID)
|
||||
}
|
||||
repos, err := db.GetByIDs(ctx, "id", repoIDs, &repo_model.Repository{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rr {
|
||||
repo := repos[r.RepoID]
|
||||
// Repos associated with an authorized integration should already be visible to the owner, but it's possible
|
||||
// that access has changed, such as a removed collaborator on a repo -- don't provide info on that repo if
|
||||
// so.
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if permission.HasAccess() {
|
||||
form.SelectedRepo = append(form.SelectedRepo, fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return form, nil
|
||||
}
|
||||
|
||||
func copyFormToAuthorizedIntegration(ctx *context.Context, form *AuthorizedIntegrationForm, ai *auth_model.AuthorizedIntegration) ([]*auth_model.AuthorizedIntegResourceRepo, error) {
|
||||
ai.Name = form.Name
|
||||
ai.Description = form.Description
|
||||
|
||||
// ui=Generic, to be refactored later
|
||||
ai.Issuer = form.Issuer
|
||||
var claimRules *auth_model.ClaimRules
|
||||
|
||||
reader := bytes.NewReader([]byte(form.ClaimRules))
|
||||
decoder := json.NewDecoder(reader)
|
||||
decoder.DisallowUnknownFields() // prevent typo fields from being ignored to make errors easier to identify
|
||||
if err := decoder.Decode(&claimRules); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", auth_service.ErrInvalidClaimRules, err)
|
||||
}
|
||||
// json.Decoder doesn't guarantee that all of the reader is consumed, which can lead to weird situations
|
||||
// where the UI appears to work correctly if extra content is in the form field, but it won't be parsed,
|
||||
// misleading users. Detect if anything other than io.EOF comes out of further decodings:
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: unexpected trailing content: %s", auth_service.ErrInvalidClaimRules, extra)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: error after JSON value: %w", auth_service.ErrInvalidClaimRules, err)
|
||||
}
|
||||
ai.ClaimRules = claimRules
|
||||
|
||||
scopeRaw := strings.Join(form.Scope, ",")
|
||||
|
||||
var resourceRepos []*auth_model.AuthorizedIntegResourceRepo
|
||||
switch form.Resource {
|
||||
case "all":
|
||||
ai.ResourceAllRepos = true
|
||||
case "public-only":
|
||||
ai.ResourceAllRepos = true
|
||||
scopeRaw = fmt.Sprintf("%s,%s", scopeRaw, auth_model.AccessTokenScopePublicOnly)
|
||||
case "repo-specific":
|
||||
ai.ResourceAllRepos = false
|
||||
selectedRepos, err := getSelectedRepos(ctx, form.SelectedRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, repo := range selectedRepos {
|
||||
resourceRepos = append(resourceRepos, &auth_model.AuthorizedIntegResourceRepo{RepoID: repo.ID})
|
||||
}
|
||||
}
|
||||
|
||||
scope, err := auth_model.AccessTokenScope(scopeRaw).Normalize()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ai.Scope = scope
|
||||
|
||||
return resourceRepos, nil
|
||||
}
|
||||
|
||||
func EditAuthorizedIntegrationRenderCommon(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations")
|
||||
ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true
|
||||
|
|
@ -370,12 +335,19 @@ func EditAuthorizedIntegrationRenderCommon(ctx *context.Context) {
|
|||
ctx.Data["Categories"] = categories
|
||||
|
||||
repoMultiSelect(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
authorizedIntegrationUI(ctx).populateTemplateContext(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplAuthorizedIntegrationViewGeneric)
|
||||
ctx.HTML(http.StatusOK, authorizedIntegrationUI(ctx).editTemplate())
|
||||
}
|
||||
|
||||
func repoMultiSelect(ctx *context.Context) {
|
||||
form := ctx.Data["Form"].(*AuthorizedIntegrationForm)
|
||||
form := ctx.Data["Form"].(authorizedIntegrationUIForm).baseForm()
|
||||
|
||||
if form.AddSelectedRepo != "" {
|
||||
form.SelectedRepo = append(form.SelectedRepo, form.AddSelectedRepo)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
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/models/unit"
|
||||
"forgejo.org/modules/base"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/svg"
|
||||
"forgejo.org/modules/templates"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/context"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
)
|
||||
|
||||
var (
|
||||
_ authorizedIntegrationUIImpl = actionsLocalUI{}
|
||||
_ authorizedIntegrationUIForm = &actionsLocalAuthorizedIntegrationForm{}
|
||||
|
||||
errInvalidWorkflowFileGlob = errors.New("invalid workflow file glob")
|
||||
errInvalidGitRefGlob = errors.New("invalid git ref glob")
|
||||
)
|
||||
|
||||
type actionsLocalUI struct{}
|
||||
|
||||
func (actionsLocalUI) UIIdentifier() auth_model.AuthorizedIntegrationUI {
|
||||
return auth_model.AuthorizedIntegrationUIForgejoActionsLocal
|
||||
}
|
||||
|
||||
func (actionsLocalUI) Icon(size int) template.HTML {
|
||||
return svg.RenderHTML("gitea-forgejo", size, "img")
|
||||
}
|
||||
|
||||
func (actionsLocalUI) Label(ctx *templates.Context) template.HTML {
|
||||
return ctx.Locale.Tr("settings.authorized_integration.ui.forgejo_actions_local")
|
||||
}
|
||||
|
||||
func (actionsLocalUI) editTemplate() base.TplName {
|
||||
return "user/settings/authorized_integrations/actions_local/view"
|
||||
}
|
||||
|
||||
func (actionsLocalUI) populateTemplateContext(ctx *context.Context) {
|
||||
// Varient of the base template's repoSingleSelect, except it supports a single-select, and filters repositories to
|
||||
// only those that enable Action unit.
|
||||
form := ctx.Data["Form"].(*actionsLocalAuthorizedIntegrationForm)
|
||||
|
||||
if form.SourceRepo != "" {
|
||||
selectedRepos, err := getSelectedRepos(ctx, []string{form.SourceRepo})
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusBadRequest, "getSelectedRepos")
|
||||
return
|
||||
} else if len(selectedRepos) != 1 {
|
||||
ctx.Error(http.StatusInternalServerError, "unexpected selectedRepos len")
|
||||
return
|
||||
}
|
||||
ctx.Data["SourceRepo"] = selectedRepos[0]
|
||||
} else {
|
||||
repoSearchText := form.ActionRepoSearch
|
||||
|
||||
page := 1
|
||||
// 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 form.ActionSetPage > 0 {
|
||||
page = form.ActionSetPage
|
||||
} else if form.ActionPage > 0 {
|
||||
page = form.ActionPage
|
||||
}
|
||||
pageSize := 10
|
||||
repoSearch := &repo_model.SearchRepoOptions{
|
||||
Actor: ctx.Doer,
|
||||
Keyword: repoSearchText,
|
||||
Private: true,
|
||||
Archived: optional.Some(false),
|
||||
EnabledUnit: optional.Some(unit.TypeActions),
|
||||
|
||||
// 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)
|
||||
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["ActionsRepos"] = repos
|
||||
|
||||
pager := context.NewPagination(int(count), pageSize, page, 3)
|
||||
pager.SetDefaultParams(ctx)
|
||||
ctx.Data["ActionsPage"] = pager
|
||||
}
|
||||
}
|
||||
|
||||
func (actionsLocalUI) form() authorizedIntegrationUIForm {
|
||||
return &actionsLocalAuthorizedIntegrationForm{}
|
||||
}
|
||||
|
||||
func (actionsLocalUI) populateError(ctx *context.Context, err error) (handled bool) {
|
||||
var errMissingField *auth_service.MissingFieldError
|
||||
switch {
|
||||
case errors.As(err, &errMissingField):
|
||||
switch errMissingField.Field {
|
||||
case "SourceRepo":
|
||||
ctx.Data["Err_SourceRepo"] = true
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.repo.required"), true)
|
||||
return true
|
||||
default:
|
||||
// Unrecognized field; fallback to server error handling.
|
||||
return false
|
||||
}
|
||||
case errors.Is(err, errInvalidWorkflowFileGlob):
|
||||
ctx.Data["Err_WorkflowFile"] = true
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.workflow_file.error", err), true)
|
||||
return true
|
||||
case errors.Is(err, errInvalidGitRefGlob):
|
||||
ctx.Data["Err_GitRef"] = true
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.forgejo_actions_local.git_ref.error", err), true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type actionsLocalAuthorizedIntegrationForm struct {
|
||||
baseAuthorizedIntegrationForm
|
||||
SourceRepo string // formatted as ownername/reponame
|
||||
WorkflowFile string
|
||||
GitRef string
|
||||
Event []string
|
||||
|
||||
// Values used for source repo search & selection, not stored in Authorized Integration. Must avoid conflicting
|
||||
// with similar form values in baseAuthorizedIntegrationForm.
|
||||
ActionRepoSearch string
|
||||
ActionPage int // repo search page
|
||||
ActionSetPage int // repo search buttons
|
||||
}
|
||||
|
||||
func (g *actionsLocalAuthorizedIntegrationForm) baseForm() *baseAuthorizedIntegrationForm {
|
||||
return &g.baseAuthorizedIntegrationForm
|
||||
}
|
||||
|
||||
func (g *actionsLocalAuthorizedIntegrationForm) isEmpty() bool {
|
||||
return g.baseAuthorizedIntegrationForm.isEmpty() && g.SourceRepo == "" && g.WorkflowFile == "" && g.GitRef == ""
|
||||
}
|
||||
|
||||
func (g *actionsLocalAuthorizedIntegrationForm) populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error {
|
||||
var err error
|
||||
var repositoryID, repositoryOwnerID int64
|
||||
|
||||
for _, cr := range claimRules.Rules {
|
||||
switch {
|
||||
case cr.Claim == "repository_id" && cr.Comparison == auth_model.ClaimEqual:
|
||||
repositoryID, err = strconv.ParseInt(cr.Value, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unexpected claim rule value on claim %q: %#v", cr.Claim, err)
|
||||
}
|
||||
case cr.Claim == "repository_owner_id" && cr.Comparison == auth_model.ClaimEqual:
|
||||
repositoryOwnerID, err = strconv.ParseInt(cr.Value, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unexpected claim rule value on claim %q: %#v", cr.Claim, err)
|
||||
}
|
||||
case cr.Claim == "workflow" && cr.Comparison == auth_model.ClaimGlob:
|
||||
g.WorkflowFile = cr.Value
|
||||
case cr.Claim == "ref" && cr.Comparison == auth_model.ClaimGlob:
|
||||
g.GitRef = cr.Value
|
||||
case cr.Claim == "event_name" && cr.Comparison == auth_model.ClaimIn:
|
||||
g.Event = cr.Values
|
||||
default:
|
||||
return fmt.Errorf("unexpected claim rule: %#v", cr)
|
||||
}
|
||||
}
|
||||
|
||||
if repositoryID == 0 && repositoryOwnerID == 0 {
|
||||
g.SourceRepo = ""
|
||||
} else {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, repositoryID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to load repo: %w", err)
|
||||
}
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !permission.HasAccess() || repo.OwnerID != repositoryOwnerID {
|
||||
return fmt.Errorf("repository could not be loaded")
|
||||
}
|
||||
g.SourceRepo = fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *actionsLocalAuthorizedIntegrationForm) convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error) {
|
||||
issuer = "urn:forgejo:authorized-integrations:actions"
|
||||
|
||||
rules := []auth_model.ClaimRule{}
|
||||
|
||||
if g.SourceRepo == "" {
|
||||
return "", nil, &auth_service.MissingFieldError{Field: "SourceRepo"}
|
||||
}
|
||||
selectedRepos, err := getSelectedRepos(ctx, []string{g.SourceRepo})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
} else if len(selectedRepos) != 1 {
|
||||
return "", nil, fmt.Errorf("expected only one repo, but received %d", len(selectedRepos))
|
||||
}
|
||||
repo := selectedRepos[0]
|
||||
// We'll translate the selected repo into two claim rules -- matching the repo's owner, and the repository,
|
||||
// based upon their immutable IDs (not their changeable names). This is a little bit inflexible as it means
|
||||
// that the authorized integration will stop accepting JWTs from the repository if the owner is changed (for
|
||||
// example, the repository is transferred), which we could permit if we just matched based upon the repository
|
||||
// ID. Matching on both fields is a bit more security conservative though; a repository transfer could be part
|
||||
// of stealing control of a repo from one admin to another, and it seems a little safer to reduce the repo's
|
||||
// access in that case and require the authorized integration owner to take some action to reestablish trust.
|
||||
rules = append(rules,
|
||||
auth_model.ClaimRule{
|
||||
Claim: "repository_id",
|
||||
Comparison: auth_model.ClaimEqual,
|
||||
Value: fmt.Sprintf("%d", repo.ID),
|
||||
},
|
||||
auth_model.ClaimRule{
|
||||
Claim: "repository_owner_id",
|
||||
Comparison: auth_model.ClaimEqual,
|
||||
Value: fmt.Sprintf("%d", repo.OwnerID),
|
||||
})
|
||||
|
||||
if g.WorkflowFile != "" {
|
||||
_, err := glob.Compile(g.WorkflowFile)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %w", errInvalidWorkflowFileGlob, err)
|
||||
}
|
||||
rules = append(rules, auth_model.ClaimRule{
|
||||
Claim: "workflow",
|
||||
Comparison: auth_model.ClaimGlob,
|
||||
Value: g.WorkflowFile,
|
||||
})
|
||||
}
|
||||
if g.GitRef != "" {
|
||||
_, err := glob.Compile(g.GitRef)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %w", errInvalidGitRefGlob, err)
|
||||
}
|
||||
rules = append(rules, auth_model.ClaimRule{
|
||||
Claim: "ref",
|
||||
Comparison: auth_model.ClaimGlob,
|
||||
Value: g.GitRef,
|
||||
})
|
||||
}
|
||||
if len(g.Event) != 0 {
|
||||
rules = append(rules, auth_model.ClaimRule{
|
||||
Claim: "event_name",
|
||||
Comparison: auth_model.ClaimIn,
|
||||
Values: g.Event,
|
||||
})
|
||||
}
|
||||
|
||||
// Safety check -- authorized integrations do support having empty claim rules, but it should never be the case for
|
||||
// the Actions Local UI to create this situation:
|
||||
if len(rules) == 0 {
|
||||
return "", nil, fmt.Errorf("unexpected: Actions Local UI didn't define any claim rules")
|
||||
}
|
||||
|
||||
claimRules = &auth_model.ClaimRules{Rules: rules}
|
||||
|
||||
return issuer, claimRules, nil
|
||||
}
|
||||
|
||||
func (g *actionsLocalAuthorizedIntegrationForm) initNew() {
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
repo_model "forgejo.org/models/repo"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/templates"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/context"
|
||||
"forgejo.org/services/contexttest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func makeLocalContext(t *testing.T) *context.Context {
|
||||
ctx, _ := contexttest.MockContext(t, "user/settings/authorized-integrations/forgejo-actions-local/new",
|
||||
contexttest.MockContextOption{Render: templates.HTMLRenderer()})
|
||||
ctx.Doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestLocalPopulateTemplateContext(t *testing.T) {
|
||||
ui := actionsLocalUI{}
|
||||
|
||||
t.Run("search for repos", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{}
|
||||
|
||||
ctx := makeLocalContext(t)
|
||||
ctx.Data["Form"] = form
|
||||
ui.populateTemplateContext(ctx)
|
||||
require.False(t, ctx.Written()) // no error written to ctx
|
||||
|
||||
actionsRepos := ctx.Data["ActionsRepos"].(repo_model.RepositoryList)
|
||||
require.Len(t, actionsRepos, 3)
|
||||
assert.Equal(t, "repo1", actionsRepos[0].Name)
|
||||
assert.Equal(t, "test_action_run_search", actionsRepos[1].Name)
|
||||
assert.Equal(t, "test_workflows", actionsRepos[2].Name)
|
||||
|
||||
pager := ctx.Data["ActionsPage"].(*context.Pagination)
|
||||
assert.Equal(t, 3, pager.Paginater.Total())
|
||||
})
|
||||
|
||||
t.Run("has repo in form", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo1",
|
||||
}
|
||||
|
||||
ctx := makeLocalContext(t)
|
||||
ctx.Data["Form"] = form
|
||||
ui.populateTemplateContext(ctx)
|
||||
require.False(t, ctx.Written()) // no error written to ctx
|
||||
|
||||
repo := ctx.Data["SourceRepo"].(*repo_model.Repository)
|
||||
assert.EqualValues(t, 1, repo.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocalPopulateError(t *testing.T) {
|
||||
ui := actionsLocalUI{}
|
||||
|
||||
t.Run("unrecognized error", func(t *testing.T) {
|
||||
ctx := makeLocalContext(t)
|
||||
assert.False(t, ui.populateError(ctx, errors.New("some other error")))
|
||||
})
|
||||
|
||||
t.Run("unrecognized field error", func(t *testing.T) {
|
||||
ctx := makeLocalContext(t)
|
||||
assert.False(t, ui.populateError(ctx, &auth_service.MissingFieldError{Field: "Description"}))
|
||||
})
|
||||
|
||||
t.Run("workflow field error", func(t *testing.T) {
|
||||
ctx := makeLocalContext(t)
|
||||
assert.True(t, ui.populateError(ctx, errInvalidWorkflowFileGlob))
|
||||
assert.True(t, ctx.Data["Err_WorkflowFile"].(bool))
|
||||
})
|
||||
|
||||
t.Run("git ref field error", func(t *testing.T) {
|
||||
ctx := makeLocalContext(t)
|
||||
assert.True(t, ui.populateError(ctx, errInvalidGitRefGlob))
|
||||
assert.True(t, ctx.Data["Err_GitRef"].(bool))
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocalPopulateForm(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{}
|
||||
issuer := "urn:forgejo:authorized-integrations:actions"
|
||||
|
||||
t.Run("fully populated claim rules", func(t *testing.T) {
|
||||
cr := &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "workflow", Comparison: auth_model.ClaimGlob, Value: ".forgejo/workflows/*.yml"},
|
||||
{Claim: "ref", Comparison: auth_model.ClaimGlob, Value: "refs/tags/v*"},
|
||||
{Claim: "event_name", Comparison: auth_model.ClaimIn, Values: []string{"push"}},
|
||||
},
|
||||
}
|
||||
require.NoError(t, form.populateForm(makeLocalContext(t), issuer, cr))
|
||||
assert.Equal(t, "user2/repo2", form.SourceRepo)
|
||||
assert.Equal(t, ".forgejo/workflows/*.yml", form.WorkflowFile)
|
||||
assert.Equal(t, "refs/tags/v*", form.GitRef)
|
||||
assert.Equal(t, []string{"push"}, form.Event)
|
||||
})
|
||||
|
||||
t.Run("mismatched repository owner", func(t *testing.T) {
|
||||
cr := &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "200"},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, form.populateForm(makeLocalContext(t), issuer, cr), "repository could not be loaded")
|
||||
})
|
||||
|
||||
t.Run("repo with no visibility", func(t *testing.T) {
|
||||
cr := &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "7"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "10"},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, form.populateForm(makeLocalContext(t), issuer, cr), "repository could not be loaded")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocalConvertForm(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{}
|
||||
_, _, err := form.convertForm(makeLocalContext(t))
|
||||
require.ErrorContains(t, err, "missing field SourceRepo")
|
||||
})
|
||||
|
||||
t.Run("repo with no visibilty", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user10/repo7",
|
||||
}
|
||||
_, _, err := form.convertForm(makeLocalContext(t))
|
||||
require.ErrorContains(t, err, "one or more of the repositories couldn't be found by owner & name")
|
||||
})
|
||||
|
||||
t.Run("valid repo", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
}
|
||||
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
|
||||
assert.Equal(t, &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
},
|
||||
}, claimRules)
|
||||
})
|
||||
|
||||
t.Run("valid workflow file", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
WorkflowFile: ".forgejo/workflows/test-*.yml",
|
||||
}
|
||||
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
|
||||
assert.Equal(t, &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "workflow", Comparison: auth_model.ClaimGlob, Value: ".forgejo/workflows/test-*.yml"},
|
||||
},
|
||||
}, claimRules)
|
||||
})
|
||||
|
||||
t.Run("invalid workflow file", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
WorkflowFile: ".forgejo/workflows/test-*[",
|
||||
}
|
||||
_, _, err := form.convertForm(makeLocalContext(t))
|
||||
require.ErrorContains(t, err, "invalid workflow file glob: unexpected end of input")
|
||||
})
|
||||
|
||||
t.Run("valid git ref", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
GitRef: "refs/tags/v*",
|
||||
}
|
||||
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
|
||||
assert.Equal(t, &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "ref", Comparison: auth_model.ClaimGlob, Value: "refs/tags/v*"},
|
||||
},
|
||||
}, claimRules)
|
||||
})
|
||||
|
||||
t.Run("invalid git ref", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
GitRef: "refs/[",
|
||||
}
|
||||
_, _, err := form.convertForm(makeLocalContext(t))
|
||||
require.ErrorContains(t, err, "invalid git ref glob: unexpected end of input")
|
||||
})
|
||||
|
||||
t.Run("valid event", func(t *testing.T) {
|
||||
form := &actionsLocalAuthorizedIntegrationForm{
|
||||
SourceRepo: "user2/repo2",
|
||||
Event: []string{"push", "pull_request"},
|
||||
}
|
||||
issuer, claimRules, err := form.convertForm(makeLocalContext(t))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "urn:forgejo:authorized-integrations:actions", issuer)
|
||||
assert.Equal(t, &auth_model.ClaimRules{
|
||||
Rules: []auth_model.ClaimRule{
|
||||
{Claim: "repository_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "repository_owner_id", Comparison: auth_model.ClaimEqual, Value: "2"},
|
||||
{Claim: "event_name", Comparison: auth_model.ClaimIn, Values: []string{"push", "pull_request"}},
|
||||
},
|
||||
}, claimRules)
|
||||
})
|
||||
}
|
||||
134
routers/web/user/setting/authorized_integrations_base.go
Normal file
134
routers/web/user/setting/authorized_integrations_base.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/services/context"
|
||||
)
|
||||
|
||||
type baseAuthorizedIntegrationForm struct {
|
||||
// Top data in UI, descriptive information about the Authorized Integration:
|
||||
Name string
|
||||
Description string
|
||||
Audience string
|
||||
|
||||
// // Middle data in UI, how JWTs are validated by this Authorized Integration:
|
||||
// Issuer string // Future: Issuer is likely to be replaced with more-specific fields on non-generic UIs
|
||||
// ClaimRules string // Future: ClaimRules is only required when aiUI == "generic"
|
||||
|
||||
// Bottom data in the UI, what authorization is permitted by this Authorized Integration:
|
||||
Resource string // all, public-only, repo-specific
|
||||
SelectedRepo []string // slice of ownername/reponame for repo-specific
|
||||
ScopeAll bool
|
||||
Scope []string
|
||||
|
||||
// Values used for repo-specific repository multi-select UI, not stored in Authorized Integration:
|
||||
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
|
||||
}
|
||||
|
||||
func (f *baseAuthorizedIntegrationForm) isEmpty() bool {
|
||||
return f.Name == "" && f.Description == "" && f.Audience == "" &&
|
||||
f.Resource == "" && f.SelectedRepo == nil && f.Scope == nil
|
||||
}
|
||||
|
||||
func (f *baseAuthorizedIntegrationForm) copyAuthorizedIntegrationToForm(ctx *context.Context, ai *auth_model.AuthorizedIntegration, rr []*auth_model.AuthorizedIntegResourceRepo) error {
|
||||
f.Name = ai.Name
|
||||
f.Description = ai.Description
|
||||
f.Audience = ai.Audience
|
||||
|
||||
if ai.ResourceAllRepos {
|
||||
publicOnly, err := ai.Scope.PublicOnly()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if publicOnly {
|
||||
f.Resource = "public-only"
|
||||
} else {
|
||||
f.Resource = "all"
|
||||
}
|
||||
} else {
|
||||
f.Resource = "repo-specific"
|
||||
}
|
||||
|
||||
f.Scope = ai.Scope.StringSlice()
|
||||
scopeAll, err := ai.Scope.HasScope(auth_model.AccessTokenScopeAll)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.ScopeAll = scopeAll
|
||||
|
||||
f.SelectedRepo = []string{}
|
||||
if len(rr) != 0 {
|
||||
repoIDs := make([]int64, len(rr))
|
||||
for i, r := range rr {
|
||||
repoIDs[i] = r.RepoID
|
||||
}
|
||||
repos, err := db.GetByIDs(ctx, "id", repoIDs, &repo_model.Repository{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rr {
|
||||
repo := repos[r.RepoID]
|
||||
// Repos associated with an authorized integration should already be visible to the owner, but it's possible
|
||||
// that access has changed, such as a removed collaborator on a repo -- don't provide info on that repo if
|
||||
// so.
|
||||
permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if permission.HasAccess() {
|
||||
f.SelectedRepo = append(f.SelectedRepo, fmt.Sprintf("%s/%s", repo.OwnerName, repo.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *baseAuthorizedIntegrationForm) copyFormToAuthorizedIntegration(ctx *context.Context, ai *auth_model.AuthorizedIntegration) ([]*auth_model.AuthorizedIntegResourceRepo, error) {
|
||||
ai.Name = f.Name
|
||||
ai.Description = f.Description
|
||||
|
||||
scopeRaw := strings.Join(f.Scope, ",")
|
||||
var resourceRepos []*auth_model.AuthorizedIntegResourceRepo
|
||||
switch f.Resource {
|
||||
case "all":
|
||||
ai.ResourceAllRepos = true
|
||||
case "public-only":
|
||||
ai.ResourceAllRepos = true
|
||||
scopeRaw = fmt.Sprintf("%s,%s", scopeRaw, auth_model.AccessTokenScopePublicOnly)
|
||||
case "repo-specific":
|
||||
ai.ResourceAllRepos = false
|
||||
selectedRepos, err := getSelectedRepos(ctx, f.SelectedRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, repo := range selectedRepos {
|
||||
resourceRepos = append(resourceRepos, &auth_model.AuthorizedIntegResourceRepo{RepoID: repo.ID})
|
||||
}
|
||||
}
|
||||
|
||||
scope, err := auth_model.AccessTokenScope(scopeRaw).Normalize()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ai.Scope = scope
|
||||
|
||||
return resourceRepos, nil
|
||||
}
|
||||
|
||||
func (f *baseAuthorizedIntegrationForm) InitNew() {
|
||||
f.Resource = "all"
|
||||
}
|
||||
115
routers/web/user/setting/authorized_integrations_generic.go
Normal file
115
routers/web/user/setting/authorized_integrations_generic.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package setting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/modules/base"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/svg"
|
||||
"forgejo.org/modules/templates"
|
||||
auth_service "forgejo.org/services/auth"
|
||||
"forgejo.org/services/context"
|
||||
)
|
||||
|
||||
var (
|
||||
_ authorizedIntegrationUIImpl = genericUI{}
|
||||
_ authorizedIntegrationUIForm = &genericAuthorizedIntegrationForm{}
|
||||
)
|
||||
|
||||
type genericUI struct{}
|
||||
|
||||
func (genericUI) UIIdentifier() auth_model.AuthorizedIntegrationUI {
|
||||
return auth_model.AuthorizedIntegrationUIGeneric
|
||||
}
|
||||
|
||||
func (genericUI) Icon(size int) template.HTML {
|
||||
return svg.RenderHTML("octicon-cloud", size, "img")
|
||||
}
|
||||
|
||||
func (genericUI) Label(ctx *templates.Context) template.HTML {
|
||||
return ctx.Locale.Tr("settings.authorized_integration.ui.generic")
|
||||
}
|
||||
|
||||
func (genericUI) editTemplate() base.TplName {
|
||||
return "user/settings/authorized_integrations/generic/view"
|
||||
}
|
||||
|
||||
func (genericUI) populateTemplateContext(ctx *context.Context) {
|
||||
}
|
||||
|
||||
func (genericUI) form() authorizedIntegrationUIForm {
|
||||
return &genericAuthorizedIntegrationForm{}
|
||||
}
|
||||
|
||||
func (genericUI) populateError(ctx *context.Context, err error) (handled bool) {
|
||||
switch {
|
||||
case errors.Is(err, auth_service.ErrInvalidIssuer):
|
||||
ctx.Data["Err_Issuer"] = true
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.issuer.invalid", err.Error()), true)
|
||||
return true
|
||||
case errors.Is(err, auth_service.ErrInvalidClaimRules):
|
||||
ctx.Data["Err_ClaimRules"] = true
|
||||
ctx.Flash.Error(ctx.Tr("settings.authorized_integration.claim_rules.invalid", err.Error()), true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type genericAuthorizedIntegrationForm struct {
|
||||
baseAuthorizedIntegrationForm
|
||||
Issuer string
|
||||
ClaimRules string
|
||||
}
|
||||
|
||||
func (g *genericAuthorizedIntegrationForm) baseForm() *baseAuthorizedIntegrationForm {
|
||||
return &g.baseAuthorizedIntegrationForm
|
||||
}
|
||||
|
||||
func (g *genericAuthorizedIntegrationForm) isEmpty() bool {
|
||||
return g.baseAuthorizedIntegrationForm.isEmpty() && g.Issuer == "" && g.ClaimRules == ""
|
||||
}
|
||||
|
||||
func (g *genericAuthorizedIntegrationForm) populateForm(ctx *context.Context, issuer string, claimRules *auth_model.ClaimRules) error {
|
||||
g.Issuer = issuer
|
||||
claimRulesJSON, err := json.MarshalIndent(claimRules, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.ClaimRules = string(claimRulesJSON)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *genericAuthorizedIntegrationForm) convertForm(ctx *context.Context) (issuer string, claimRules *auth_model.ClaimRules, err error) {
|
||||
issuer = g.Issuer
|
||||
|
||||
reader := bytes.NewReader([]byte(g.ClaimRules))
|
||||
decoder := json.NewDecoder(reader)
|
||||
decoder.DisallowUnknownFields() // prevent typo fields from being ignored to make errors easier to identify
|
||||
if err := decoder.Decode(&claimRules); err != nil {
|
||||
return "", nil, fmt.Errorf("%w: %w", auth_service.ErrInvalidClaimRules, err)
|
||||
}
|
||||
// json.Decoder doesn't guarantee that all of the reader is consumed, which can lead to weird situations
|
||||
// where the UI appears to work correctly if extra content is in the form field, but it won't be parsed,
|
||||
// misleading users. Detect if anything other than io.EOF comes out of further decodings:
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return "", nil, fmt.Errorf("%w: unexpected trailing content: %s", auth_service.ErrInvalidClaimRules, extra)
|
||||
}
|
||||
return "", nil, fmt.Errorf("%w: error after JSON value: %w", auth_service.ErrInvalidClaimRules, err)
|
||||
}
|
||||
|
||||
return issuer, claimRules, nil
|
||||
}
|
||||
|
||||
func (g *genericAuthorizedIntegrationForm) initNew() {
|
||||
g.ClaimRules = string("{\n \"rules\":[]\n}")
|
||||
}
|
||||
|
|
@ -674,12 +674,12 @@ func registerRoutes(m *web.Route) {
|
|||
m.Group("/authorized-integrations", func() {
|
||||
m.Group("/{ui}", func() {
|
||||
m.Combo("/new").
|
||||
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.NewAuthorizedIntegration).
|
||||
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.NewAuthorizedIntegrationPost)
|
||||
Get(user_setting.NewAuthorizedIntegration).
|
||||
Post(user_setting.NewAuthorizedIntegrationPost)
|
||||
m.Combo("/{id}").
|
||||
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration).
|
||||
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost)
|
||||
})
|
||||
Get(user_setting.EditAuthorizedIntegration).
|
||||
Post(user_setting.EditAuthorizedIntegrationPost)
|
||||
}, user_setting.BindAuthorizedIntegrationUI, user_setting.DynamicBindAuthorizedIntegrationForm)
|
||||
m.Post("/delete", user_setting.DeleteAuthorizedIntegration)
|
||||
m.Get("", user_setting.ListAuthorizedIntegrations)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
{{template "user/settings/authorized_integrations/view_head" .}}
|
||||
|
||||
<h4 class="ui top attached header {{if .Err_SourceRepo}}error{{end}}">
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.ui.forgejo_actions_local"}}
|
||||
</h4>
|
||||
<div class="ui attached bottom segment">
|
||||
<p>{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.description"}}</p>
|
||||
|
||||
{{if eq .Form.SourceRepo ""}}
|
||||
<div 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 id="action-select-repo">
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.select_repository"}}
|
||||
</h5>
|
||||
|
||||
<div class="ui small fluid action left icon input tw-mb-3">
|
||||
<input type="search" name="action_repo_search" spellcheck="false" placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.Form.ActionRepoSearch}}">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<button class="ui small icon button" aria-label="{{ctx.Locale.Tr "search.search"}}" type="submit" name="action_set_page" value="1" formnovalidate="true" formmethod="get">
|
||||
{{svg "octicon-search" 16}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{if eq (len .ActionsRepos) 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 .ActionsRepos}}
|
||||
{{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="source_repo" value="{{.FullName}}" formmethod="get">
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.select_repo"}}
|
||||
</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 .ActionsPage.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="action_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="action_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="action_set_page" value="{{.Num}}" formmethod="get">
|
||||
{{.Num}}
|
||||
</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<button class="item navigation {{if not .HasNext}}disabled{{end}}" type="submit" formnovalidate="true" name="action_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="action_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>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="field tw-flex tw-items-center">
|
||||
<div class="tw-mr-2">Source Repository:</div>
|
||||
{{template "user/settings/repo_icon" .SourceRepo}}
|
||||
<div class="text truncate">
|
||||
{{.SourceRepo.FullName}}
|
||||
</div>
|
||||
<button class="ui primary button tw-ml-2 tw-my-1 tiny" formnovalidate="true" formmethod="get" name="source_repo" value="">
|
||||
Change
|
||||
</button>
|
||||
<input type="hidden" name="source_repo" value="{{.Form.SourceRepo}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="field {{if .Err_WorkflowFile}}error{{end}}">
|
||||
<label for="workflow_file">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.workflow_file.label"}}</label>
|
||||
<input id="workflow_file" name="workflow_file" value="{{.Form.WorkflowFile}}">
|
||||
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.workflow_file.help" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}}</span>
|
||||
</div>
|
||||
|
||||
<div class="field {{if .Err_GitRef}}error{{end}}">
|
||||
<label for="git_ref">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.git_ref.label"}}</label>
|
||||
<input id="git_ref" name="git_ref" value="{{.Form.GitRef}}">
|
||||
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.git_ref.help" "https://pkg.go.dev/github.com/gobwas/glob#Compile" "github.com/gobwas/glob"}}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="event">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.event.label"}}</label>
|
||||
<select id="event" name="event" multiple class="ui selection dropdown">
|
||||
<option value="pull_request" {{if (SliceUtils.Contains .Form.Event "pull_request")}}selected{{end}}>pull_request</option>
|
||||
<option value="push" {{if (SliceUtils.Contains .Form.Event "push")}}selected{{end}}>push</option>
|
||||
<option value="issues" {{if (SliceUtils.Contains .Form.Event "issues")}}selected{{end}}>issues</option>
|
||||
<option value="pull_request_target" {{if (SliceUtils.Contains .Form.Event "pull_request_target")}}selected{{end}}>pull_request_target</option>
|
||||
<option value="release" {{if (SliceUtils.Contains .Form.Event "release")}}selected{{end}}>release</option>
|
||||
<option value="schedule" {{if (SliceUtils.Contains .Form.Event "schedule")}}selected{{end}}>schedule</option>
|
||||
<option value="workflow_dispatch" {{if (SliceUtils.Contains .Form.Event "workflow_dispatch")}}selected{{end}}>workflow_dispatch</option>
|
||||
</select>
|
||||
<span class="help">{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.event.help"}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "user/settings/authorized_integrations/view_footer" .}}
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
<textarea id="claim_rules" name="claim_rules" class="tw-hidden">{{.Form.ClaimRules}}</textarea>
|
||||
{{template "shared/codemirror_container" .}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{template "user/settings/authorized_integrations/view_footer" .}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<div class="menu">
|
||||
<a class="item" href="./authorized-integrations/generic/new">
|
||||
{{svg "octicon-cloud" 20}}
|
||||
{{ctx.Locale.Tr "settings.authorized_integration.generic"}}
|
||||
</a>
|
||||
{{range .UIs}}
|
||||
<a class="item" href="./authorized-integrations/{{.UIIdentifier}}/new">
|
||||
{{.Icon 20}}
|
||||
{{.Label ctx}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
</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}}">
|
||||
<input type="search" name="repo_search" spellcheck="false" {{if eq .Autofocus "search"}}autofocus{{end}} placeholder="{{ctx.Locale.Tr "search.repo_kind"}}" value="{{.Form.RepoSearch}}">
|
||||
<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" formaction="#resource-repo-specific">
|
||||
{{svg "octicon-search" 16}}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ test('User: Add specific repo access token', async ({browser}, workerInfo) => {
|
|||
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');
|
||||
await expect(page.getByPlaceholder('Search repos…')).toHaveValue('big_test_private_4'); // search box still populated after reload
|
||||
|
||||
// Add the big_test_private_4 repo.
|
||||
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
|
||||
|
|
@ -409,6 +410,7 @@ test('User: Edit authorized integration specific repo', async ({browser}, worker
|
|||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue(/^Example AI/);
|
||||
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
|
||||
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
|
||||
await expect(page.getByPlaceholder('Search repos…')).toHaveValue('big_test_private_4'); // search box still populated after reload
|
||||
|
||||
// Add the big_test_private_4 repo.
|
||||
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
|
||||
|
|
@ -442,7 +444,7 @@ test('User: Add authorized integration', async ({browser}, workerInfo) => {
|
|||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
|
||||
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
|
||||
await page.getByRole('menuitem', {name: 'Generic JWT'}).click();
|
||||
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('');
|
||||
|
|
@ -482,7 +484,7 @@ test('User: Add authorized integration validation error', async ({browser}, work
|
|||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
|
||||
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
|
||||
await page.getByRole('menuitem', {name: 'Generic JWT'}).click();
|
||||
|
||||
await page.getByRole('textbox', {name: 'Name'}).fill('\t\t');
|
||||
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('urn:forgejo:authorized-integrations:actions');
|
||||
|
|
@ -511,3 +513,70 @@ test('User: Add authorized integration validation error', async ({browser}, work
|
|||
const deleteFlashText = await page.locator('.ui.message.flash-success').textContent();
|
||||
expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.');
|
||||
});
|
||||
|
||||
test('User: Add authorized integration (actions local)', async ({browser}, workerInfo) => {
|
||||
const page = await login({browser}, workerInfo);
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
|
||||
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
|
||||
await page.getByRole('menuitem', {name: 'Forgejo Actions (Local)'}).click();
|
||||
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('');
|
||||
|
||||
await page.getByRole('textbox', {name: 'Name'}).fill('New Forgejo Actions (Local) Integration!');
|
||||
await page.getByRole('textbox', {name: 'Description'}).fill('Description that carefully describes things.');
|
||||
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
|
||||
await page.getByRole('button', {name: 'Create authorized integration'}).click();
|
||||
|
||||
// Didn't select a repository as the source, so an error is expected:
|
||||
await expect(page.locator('.flash-error')).toContainText('Forgejo Actions source repository must be selected.');
|
||||
|
||||
// Verify that initial search results for the "Select repository:" section are visible
|
||||
const actionsLocalSectionGetter = async () => await page.locator('.ui.attached.segment').filter({has: page.locator('p', {hasText: 'Forgejo Actions will be able to access Forgejo'})});
|
||||
let actionsLocalSection = await actionsLocalSectionGetter();
|
||||
await expect(actionsLocalSection.getByText('user2/diff-test')).toBeVisible();
|
||||
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible(); // another repo, will be used to verify search worked
|
||||
await actionsLocalSection.getByPlaceholder('Search repos…').fill('huge-diff');
|
||||
await actionsLocalSection.getByRole('button', {name: 'Search…'}).click();
|
||||
|
||||
// verify search results visible:
|
||||
actionsLocalSection = await actionsLocalSectionGetter();
|
||||
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
|
||||
await expect(actionsLocalSection.getByText('user2/diff-test')).toBeHidden();
|
||||
|
||||
// after performing a search, verify that other fields haven't lost their form values:
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('New Forgejo Actions (Local) Integration!');
|
||||
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
|
||||
await expect(actionsLocalSection.getByPlaceholder('Search repos…')).toHaveValue('huge-diff'); // search box still populated after reload
|
||||
|
||||
// Add the big_test_private_4 repo.
|
||||
await actionsLocalSection.getByRole('button', {name: 'Add user2/huge-diff-test'}).click();
|
||||
actionsLocalSection = await actionsLocalSectionGetter();
|
||||
await expect(actionsLocalSection.getByText('Source repository:')).toBeVisible();
|
||||
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', {name: 'Create authorized integration'}).click();
|
||||
|
||||
// Create will reload the page with a success banner, and the audience now populated; re-verify the Forgejo Actions repo selection as well:
|
||||
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('New Forgejo Actions (Local) Integration!');
|
||||
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toHaveValue(/^u:[0-9]+/);
|
||||
actionsLocalSection = await actionsLocalSectionGetter();
|
||||
await expect(actionsLocalSection.getByText('Source repository:')).toBeVisible();
|
||||
await expect(actionsLocalSection.getByText('user2/huge-diff-test')).toBeVisible();
|
||||
|
||||
// Flash banner:
|
||||
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
|
||||
const flashText = await page.locator('.ui.message.flash-success').textContent();
|
||||
expect(flashText?.trim()).toBe('Created authorized integration: New Forgejo Actions (Local) Integration!');
|
||||
|
||||
// Delete the added integration, minimizing left-over test data and also validating the delete UI:
|
||||
await page.goto('/user/settings/authorized-integrations');
|
||||
await page.locator('.flex-item')
|
||||
.filter({has: page.locator('.flex-item-title', {hasText: 'New Forgejo Actions (Local) Integration!'})})
|
||||
.getByRole('button', {name: 'Delete'}).click();
|
||||
await page.getByRole('button', {name: 'Yes'}).click();
|
||||
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
|
||||
const deleteFlashText = await page.locator('.ui.message.flash-success').textContent();
|
||||
expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import $ from 'jquery';
|
|||
import {createCodemirror} from './codemirror.ts';
|
||||
|
||||
export function initAuthorizedIntegrationClaimRuleEditor() {
|
||||
if (!$('.user.authorized-integrations').length) return;
|
||||
if (!$('.user.authorized-integrations #claim_rules').length) return;
|
||||
const _promise = createCodemirror($('#claim_rules')[0], 'claims.json', {language: 'JSON'});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue