From 6d522ecba00c64ab83a365bd97907afc6feae278 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Sat, 23 May 2026 16:26:28 +0200 Subject: [PATCH] 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 --- models/repo/repo_list.go | 6 + models/repo/repo_list_test.go | 6 + options/locale_next/locale_en-US.json | 13 +- .../user/setting/authorized_integrations.go | 310 ++++++++---------- .../authorized_integrations_actions_local.go | 289 ++++++++++++++++ ...horized_integrations_actions_local_test.go | 232 +++++++++++++ .../setting/authorized_integrations_base.go | 134 ++++++++ .../authorized_integrations_generic.go | 115 +++++++ routers/web/web.go | 10 +- .../actions_local/view.tmpl | 123 +++++++ .../authorized_integrations/generic/view.tmpl | 1 - .../authorized_integrations/link_menu.tmpl | 10 +- .../authorized_integrations/view_footer.tmpl | 2 +- tests/e2e/user-settings.test.e2e.ts | 73 ++++- web_src/js/features/authorized-integration.js | 2 +- 15 files changed, 1142 insertions(+), 184 deletions(-) create mode 100644 routers/web/user/setting/authorized_integrations_actions_local.go create mode 100644 routers/web/user/setting/authorized_integrations_actions_local_test.go create mode 100644 routers/web/user/setting/authorized_integrations_base.go create mode 100644 routers/web/user/setting/authorized_integrations_generic.go create mode 100644 templates/user/settings/authorized_integrations/actions_local/view.tmpl diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index a0eb0ce7c2..7fc4dd44a4 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -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 } diff --git a/models/repo/repo_list_test.go b/models/repo/repo_list_test.go index fc32226975..a2467855a7 100644 --- a/models/repo/repo_list_test.go +++ b/models/repo/repo_list_test.go @@ -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 diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 8cadf4914c..f2ae4c0943 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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 (aud) 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 %[2]s documentation for pattern syntax.
Examples: testing.yml, test-*.yml.", + "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 %[2]s documentation for pattern syntax.
Examples: refs/heads/main, refs/pull/*/head.", + "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…", diff --git a/routers/web/user/setting/authorized_integrations.go b/routers/web/user/setting/authorized_integrations.go index f7e6cc7f96..6ce254e202 100644 --- a/routers/web/user/setting/authorized_integrations.go +++ b/routers/web/user/setting/authorized_integrations.go @@ -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) diff --git a/routers/web/user/setting/authorized_integrations_actions_local.go b/routers/web/user/setting/authorized_integrations_actions_local.go new file mode 100644 index 0000000000..8557660b43 --- /dev/null +++ b/routers/web/user/setting/authorized_integrations_actions_local.go @@ -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() { +} diff --git a/routers/web/user/setting/authorized_integrations_actions_local_test.go b/routers/web/user/setting/authorized_integrations_actions_local_test.go new file mode 100644 index 0000000000..0461ff6c2e --- /dev/null +++ b/routers/web/user/setting/authorized_integrations_actions_local_test.go @@ -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) + }) +} diff --git a/routers/web/user/setting/authorized_integrations_base.go b/routers/web/user/setting/authorized_integrations_base.go new file mode 100644 index 0000000000..f0d732853d --- /dev/null +++ b/routers/web/user/setting/authorized_integrations_base.go @@ -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" +} diff --git a/routers/web/user/setting/authorized_integrations_generic.go b/routers/web/user/setting/authorized_integrations_generic.go new file mode 100644 index 0000000000..0f29b5c57a --- /dev/null +++ b/routers/web/user/setting/authorized_integrations_generic.go @@ -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}") +} diff --git a/routers/web/web.go b/routers/web/web.go index b23ab738bf..ba9ad277db 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) }) diff --git a/templates/user/settings/authorized_integrations/actions_local/view.tmpl b/templates/user/settings/authorized_integrations/actions_local/view.tmpl new file mode 100644 index 0000000000..aa24f23597 --- /dev/null +++ b/templates/user/settings/authorized_integrations/actions_local/view.tmpl @@ -0,0 +1,123 @@ +{{template "user/settings/authorized_integrations/view_head" .}} + +

+ {{ctx.Locale.Tr "settings.authorized_integration.ui.forgejo_actions_local"}} +

+
+

{{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.description"}}

+ + {{if eq .Form.SourceRepo ""}} +
+ +
+
+ {{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.select_repository"}} +
+ +
+ + {{svg "octicon-search" 16}} + +
+ + {{if eq (len .ActionsRepos) 0}} + {{ctx.Locale.Tr "settings.access_token.no_repositories_found"}} + {{else}} +
+ {{range .ActionsRepos}} + {{template "user/settings/repo_icon" .}} +
+ {{.FullName}} +
+ + {{end}} +
+ {{end}} + + {{/* Can't use base/paginate template include here because all the pagination links in + that template are simple 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}} + +
+ +
+ {{end}} +
+
+ {{else}} +
+
Source Repository:
+ {{template "user/settings/repo_icon" .SourceRepo}} +
+ {{.SourceRepo.FullName}} +
+ + +
+ {{end}} + +
+ + + {{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"}} +
+ +
+ + + {{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"}} +
+ +
+ + + {{ctx.Locale.Tr "settings.authorized_integration.forgejo_actions_local.event.help"}} +
+
+ +{{template "user/settings/authorized_integrations/view_footer" .}} diff --git a/templates/user/settings/authorized_integrations/generic/view.tmpl b/templates/user/settings/authorized_integrations/generic/view.tmpl index 8be4068146..3a543bcee3 100644 --- a/templates/user/settings/authorized_integrations/generic/view.tmpl +++ b/templates/user/settings/authorized_integrations/generic/view.tmpl @@ -14,7 +14,6 @@ {{template "shared/codemirror_container" .}} - {{template "user/settings/authorized_integrations/view_footer" .}} diff --git a/templates/user/settings/authorized_integrations/link_menu.tmpl b/templates/user/settings/authorized_integrations/link_menu.tmpl index 16b401a9f4..cf8c6bbb0a 100644 --- a/templates/user/settings/authorized_integrations/link_menu.tmpl +++ b/templates/user/settings/authorized_integrations/link_menu.tmpl @@ -1,6 +1,8 @@ diff --git a/templates/user/settings/authorized_integrations/view_footer.tmpl b/templates/user/settings/authorized_integrations/view_footer.tmpl index 511bae301d..ef081a008a 100644 --- a/templates/user/settings/authorized_integrations/view_footer.tmpl +++ b/templates/user/settings/authorized_integrations/view_footer.tmpl @@ -46,7 +46,7 @@
- + {{svg "octicon-search" 16}}