From ba3619d1df5a608e5a31d3d70dd45694267f9801 Mon Sep 17 00:00:00 2001 From: Mathieu Fenniak Date: Sun, 10 May 2026 18:27:43 -0600 Subject: [PATCH] feat(ui): list authorized integrations --- models/auth/authorized_integration.go | 26 ++++++++++ models/auth/authorized_integration_test.go | 49 +++++++++++++++++++ options/locale_next/locale_en-US.json | 6 +++ .../user/setting/authorized_integrations.go | 33 +++++++++++++ routers/web/web.go | 4 ++ .../settings/authorized_integrations.tmpl | 45 +++++++++++++++++ templates/user/settings/navbar.tmpl | 3 ++ tests/integration/integration_test.go | 1 + tests/integration/user_test.go | 28 +++++++++++ 9 files changed, 195 insertions(+) create mode 100644 routers/web/user/setting/authorized_integrations.go create mode 100644 templates/user/settings/authorized_integrations.tmpl diff --git a/models/auth/authorized_integration.go b/models/auth/authorized_integration.go index a401757d55..72ab59286f 100644 --- a/models/auth/authorized_integration.go +++ b/models/auth/authorized_integration.go @@ -10,6 +10,7 @@ import ( "time" "forgejo.org/models/db" + "forgejo.org/modules/optional" "forgejo.org/modules/timeutil" "forgejo.org/modules/util" @@ -210,3 +211,28 @@ func (ai *AuthorizedIntegration) generateAudience() error { ai.Audience = fmt.Sprintf("u:%d:%s", ai.UserID, gouuid.New().String()) return nil } + +func (ai *AuthorizedIntegration) HasRecentActivity() bool { + return ai.HasBeenUsed() && ai.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() +} + +func (ai *AuthorizedIntegration) HasBeenUsed() bool { + return ai.UpdatedUnix > ai.CreatedUnix +} + +type ListAuthorizedIntegrationOptions struct { + db.ListOptions + UserID optional.Option[int64] +} + +func (opts ListAuthorizedIntegrationOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if has, userID := opts.UserID.Get(); has { + cond = cond.And(builder.Eq{"user_id": userID}) + } + return cond +} + +func (opts ListAuthorizedIntegrationOptions) ToOrders() string { + return "created_unix DESC" +} diff --git a/models/auth/authorized_integration_test.go b/models/auth/authorized_integration_test.go index d705f1a144..ff6d399827 100644 --- a/models/auth/authorized_integration_test.go +++ b/models/auth/authorized_integration_test.go @@ -10,6 +10,7 @@ import ( auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unittest" + "forgejo.org/modules/optional" "forgejo.org/modules/timeutil" "forgejo.org/modules/util" @@ -104,3 +105,51 @@ func TestNewAuthorizedIntegration(t *testing.T) { } require.ErrorContains(t, auth_model.InsertAuthorizedIntegration(t.Context(), ai), "UserID must be initialized") } + +func TestAuthorizedIntegrationCalculatedValues(t *testing.T) { + t.Run("HasRecentActivity", func(t *testing.T) { + timeutil.MockSet(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + ai := &auth_model.AuthorizedIntegration{ + UpdatedUnix: 1609459200, + CreatedUnix: 1609459200, + } + assert.False(t, ai.HasRecentActivity()) + ai.UpdatedUnix = 1609459201 + assert.True(t, ai.HasRecentActivity()) + ai.CreatedUnix = 1577836800 + ai.UpdatedUnix = 1577836801 + assert.False(t, ai.HasRecentActivity()) + }) + + t.Run("HasBeenUsed", func(t *testing.T) { + ai := &auth_model.AuthorizedIntegration{ + UpdatedUnix: 1, + CreatedUnix: 1, + } + assert.False(t, ai.HasBeenUsed()) + ai.UpdatedUnix = 2 + assert.True(t, ai.HasBeenUsed()) + }) +} + +func TestListAuthorizedIntegrationOptions(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + makeAuthorizedIntegration(t) + makeAuthorizedIntegration(t) + + ais, err := db.Find[auth_model.AuthorizedIntegration](t.Context(), + auth_model.ListAuthorizedIntegrationOptions{UserID: optional.None[int64]()}) + require.NoError(t, err) + assert.Len(t, ais, 2) + + ais, err = db.Find[auth_model.AuthorizedIntegration](t.Context(), + auth_model.ListAuthorizedIntegrationOptions{UserID: optional.Some(int64(2))}) + require.NoError(t, err) + assert.Len(t, ais, 2) + + ais, err = db.Find[auth_model.AuthorizedIntegration](t.Context(), + auth_model.ListAuthorizedIntegrationOptions{UserID: optional.Some(int64(22))}) + require.NoError(t, err) + assert.Empty(t, ais) +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 614f3c1ce9..15e234717f 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -318,6 +318,12 @@ "settings.access_token.resource_public_only_help": "Limit access to repositories and organizations that are public.", "settings.access_token.resource_specific_repo_help": "Limit access to a specific list of repositories. Read-only access is permitted to all public repositories. Only permissions that allow access to repositories and issues can be enabled.", "settings.access_token.admin_disabled": "Administrative permissions are disabled.", + "settings.authorized_integrations": "Authorized Integrations", + "settings.manage_authorized_integrations": "Authorized integrations", + "settings.authorized_integration.desc": "Authorized integrations allow Forgejo to receive signed JWTs, validate their claims against configured rules, and permit them to access Forgejo's APIs.", + "settings.authorized_integration.ui.generic": "Generic JWT", + "settings.authorized_integration.ui.forgejo_actions_local": "Forgejo Actions (Local)", + "settings.authorized_integration.none": "No authorized integrations currently configured.", "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 new file mode 100644 index 0000000000..4268856a74 --- /dev/null +++ b/routers/web/user/setting/authorized_integrations.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import ( + "net/http" + + auth_model "forgejo.org/models/auth" + "forgejo.org/models/db" + "forgejo.org/modules/base" + "forgejo.org/modules/optional" + "forgejo.org/services/context" +) + +const ( + tplSettingsAuthorizedIntegrations base.TplName = "user/settings/authorized_integrations" +) + +func ListAuthorizedIntegrations(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.authorized_integrations") + ctx.Data["PageIsSettingsAuthorizedIntegrations"] = true + + ais, err := db.Find[auth_model.AuthorizedIntegration](ctx, + auth_model.ListAuthorizedIntegrationOptions{UserID: optional.Some(ctx.Doer.ID)}) + if err != nil { + ctx.ServerError("ListAuthorizedIntegrations", err) + return + } + ctx.Data["AuthorizedIntegrations"] = ais + + ctx.HTML(http.StatusOK, tplSettingsAuthorizedIntegrations) +} diff --git a/routers/web/web.go b/routers/web/web.go index c19ecfd411..a5fc7cf720 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -670,6 +670,10 @@ func registerRoutes(m *web.Route) { m.Get("", user_setting.Applications) }) + m.Group("/authorized-integrations", func() { + m.Get("", user_setting.ListAuthorizedIntegrations) + }) + m.Combo("/keys").Get(user_setting.Keys). Post(web.Bind(forms.AddKeyForm{}), user_setting.KeysPost) m.Post("/keys/delete", user_setting.DeleteKey) diff --git a/templates/user/settings/authorized_integrations.tmpl b/templates/user/settings/authorized_integrations.tmpl new file mode 100644 index 0000000000..3dbd2920d4 --- /dev/null +++ b/templates/user/settings/authorized_integrations.tmpl @@ -0,0 +1,45 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings applications")}} + +
+

+ {{ctx.Locale.Tr "settings.manage_authorized_integrations"}} +

+
+
+
+ {{ctx.Locale.Tr "settings.authorized_integration.desc"}} +
+ {{range .AuthorizedIntegrations}} +
+
+ {{if eq .UI "forgejo-actions-local"}} + + {{svg "gitea-forgejo" 32}} + + {{else}} + + {{svg "octicon-cloud" 32}} + + {{end}} +
+
+ {{.Name}} +
+

{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}} — {{svg "octicon-info"}} {{if .HasBeenUsed}}{{ctx.Locale.Tr "settings.last_used"}} {{DateUtils.AbsoluteShort .UpdatedUnix}}{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}

+
+
+
+ {{else}} +
+
+
+

{{ctx.Locale.Tr "settings.authorized_integration.none"}}

+
+
+
+ {{end}} +
+
+
+ +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 325fdada7d..c6012ba437 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -17,6 +17,9 @@ {{ctx.Locale.Tr "settings.applications"}} + + {{ctx.Locale.Tr "settings.authorized_integrations"}} + {{ctx.Locale.Tr "settings.ssh_gpg_keys"}} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index a75768bbaa..850bd57128 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -851,6 +851,7 @@ func newAITester(t *testing.T, setupAI ...func(*auth.AuthorizedIntegration)) *Au }, }, ResourceAllRepos: true, + Name: fmt.Sprintf("AI %s", t.Name()), } for _, setup := range setupAI { setup(ait.authorizedIntegration) diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index f968f8df81..358a549af9 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -1301,3 +1301,31 @@ func TestExportUserSSHKeys(t *testing.T) { assert.Equal(t, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDN7KuFUnlztx/UM6PUTyiBAq5SeIqr+qSVFC6JzLQAh\n", resp.Body.String()) }) } + +func TestAuthorizedIntegrationList(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + locale := translation.NewLocale("en-US") + topDescription := locale.TrString("settings.authorized_integration.desc") + noAI := locale.TrString("settings.authorized_integration.none") + + session := loginUser(t, "user2") + + // Load page with no authorized integrations: + req := NewRequest(t, "GET", "/user/settings/authorized-integrations") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item", topDescription), true) + htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item-body p", noAI), true) + + ait := newAITester(t) + defer ait.close() + + // Load page which should now have a generic authorized integration: + req = NewRequest(t, "GET", "/user/settings/authorized-integrations") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item", topDescription), true) + htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item-body p", noAI), false) // "no ... configured" no longer present + htmlDoc.AssertSelection(t, htmlDoc.FindByTextTrim("div.flex-item span.flex-item-title", "AI TestAuthorizedIntegrationList"), true) +}