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.forgejo_actions_local.description"}}
+ + {{if eq .Form.SourceRepo ""}} +