mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-28 11:14:54 -04:00
feat(ui): list authorized integrations
This commit is contained in:
parent
2327b3b888
commit
ba3619d1df
9 changed files with 195 additions and 0 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…",
|
||||
|
|
|
|||
33
routers/web/user/setting/authorized_integrations.go
Normal file
33
routers/web/user/setting/authorized_integrations.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
45
templates/user/settings/authorized_integrations.tmpl
Normal file
45
templates/user/settings/authorized_integrations.tmpl
Normal 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" .}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue