feat(ui): list authorized integrations

This commit is contained in:
Mathieu Fenniak 2026-05-10 18:27:43 -06:00 committed by Mathieu Fenniak
parent 2327b3b888
commit ba3619d1df
9 changed files with 195 additions and 0 deletions

View file

@ -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"
}

View file

@ -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)
}

View file

@ -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…",

View file

@ -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)
}

View file

@ -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)

View file

@ -0,0 +1,45 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings applications")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_authorized_integrations"}}
</h4>
<div class="ui attached segment">
<div class="flex-list">
<div class="flex-item">
{{ctx.Locale.Tr "settings.authorized_integration.desc"}}
</div>
{{range .AuthorizedIntegrations}}
<div class="flex-item">
<div class="flex-item-leading">
{{if eq .UI "forgejo-actions-local"}}
<span role="img" aria-label="{{ctx.Locale.Tr "settings.authorized_integration.ui.forgejo_actions_local"}}" title="{{ctx.Locale.Tr "settings.authorized_integration.ui.forgejo_actions_local"}}">
{{svg "gitea-forgejo" 32}}
</span>
{{else}}
<span role="img" aria-label="{{ctx.Locale.Tr "settings.authorized_integration.ui.generic"}}" title="{{ctx.Locale.Tr "settings.authorized_integration.ui.generic"}}">
{{svg "octicon-cloud" 32}}
</span>
{{end}}
</div>
<div class="flex-item-main">
<span class="flex-item-title">{{.Name}}</span>
<div class="flex-item-body">
<p>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasBeenUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</p>
</div>
</div>
</div>
{{else}}
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-item-body">
<p>{{ctx.Locale.Tr "settings.authorized_integration.none"}}</p>
</div>
</div>
</div>
{{end}}
</div>
</div>
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -17,6 +17,9 @@
<a class="{{if .PageIsSettingsApplications}}active {{end}}item" href="{{AppSubUrl}}/user/settings/applications">
{{ctx.Locale.Tr "settings.applications"}}
</a>
<a class="{{if .PageIsSettingsAuthorizedIntegrations}}active {{end}}item" href="{{AppSubUrl}}/user/settings/authorized-integrations">
{{ctx.Locale.Tr "settings.authorized_integrations"}}
</a>
<a class="{{if .PageIsSettingsKeys}}active {{end}}item" href="{{AppSubUrl}}/user/settings/keys">
{{ctx.Locale.Tr "settings.ssh_gpg_keys"}}
</a>

View file

@ -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)

View file

@ -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)
}