feat: create new authorized integration in web UI (#12613)

Extends work completed in #12601 to enable creating new authorized integrations in the web UI.  This UI is identical to the edit experience, except: "Audience" is only presented once the object is saved, "Save authorized integration is changed to "Create authorized integration", and performing the create redirects to the completed object to access the audience rather than redirecting the list page.

A drop-down menu is used for the "UI" of the new authorized integration, even though only the generic "write your own rule" UI is currently implemented.

## 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...
  - [ ] 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)).
      - e2e tests here are for complete experience, but aren't for "JavaScript changes" as noted here.

### 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 coming soon.

### 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/12613
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
This commit is contained in:
Mathieu Fenniak 2026-05-18 16:13:57 +02:00 committed by Mathieu Fenniak
parent 0af02256ae
commit 8d50e7b25e
8 changed files with 172 additions and 31 deletions

View file

@ -327,7 +327,10 @@
"settings.authorized_integration.none": "No authorized integrations currently configured.",
"settings.authorized_integration.edit": "Edit",
"settings.authorized_integration.edit_page_title": "Authorized Integration <b>%s</b>",
"settings.authorized_integration.create_page_title": "Create Authorized Integration",
"settings.authorized_integration.save": "Save authorized integration",
"settings.authorized_integration.create": "Create authorized integration",
"settings.authorized_integration.create_success": "Created authorized integration: %s",
"settings.authorized_integration.field.name": "Name",
"settings.authorized_integration.field.description": "Description",
"settings.authorized_integration.field.description.placeholder": "Used to publish packages when ...",
@ -343,6 +346,8 @@
"settings.authorized_integration.specified_repos_none": "Authorized integrations with specified repositories must have at least one repository.",
"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",
"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

@ -74,6 +74,11 @@ type AuthorizedIntegrationForm struct {
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)
@ -102,7 +107,7 @@ func EditAuthorizedIntegration(ctx *context.Context) {
}
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
if form.Audience == "" { // empty GET; first load of the page
if form.isEmpty() {
repos, err := auth_model.GetRepositoriesAccessibleWithIntegration(ctx, ai.ID)
if err != nil {
ctx.ServerError("GetRepositoriesAccessibleWithIntegration", err)
@ -143,6 +148,42 @@ func EditAuthorizedIntegrationPost(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/authorized-integrations")
}
func NewAuthorizedIntegration(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
if form.isEmpty() {
form.Resource = "all"
form.ClaimRules = string("{\n \"rules\":[]\n}")
}
ctx.Data["Form"] = form
ctx.Data["IsNew"] = true
EditAuthorizedIntegrationRenderCommon(ctx)
}
func NewAuthorizedIntegrationPost(ctx *context.Context) {
form := web.GetForm(ctx).(*AuthorizedIntegrationForm)
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,
}
rr, err := copyFormToAuthorizedIntegration(ctx, form, ai)
if err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
if err := auth_service.InsertAuthorizedIntegration(ctx, ai, rr); err != nil {
editAuthorizedIntegrationErrorHandler(ctx, err)
return
}
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))
}
func editAuthorizedIntegrationErrorHandler(ctx *context.Context, err error) {
var errMissingField *auth_service.MissingFieldError
switch {

View file

@ -672,9 +672,14 @@ func registerRoutes(m *web.Route) {
})
m.Group("/authorized-integrations", func() {
m.Combo("/{ui}/{id}").
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration).
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost)
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)
m.Combo("/{id}").
Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration).
Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost)
})
m.Get("", user_setting.ListAuthorizedIntegrations)
})

View file

@ -3,6 +3,12 @@
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.manage_authorized_integrations"}}
<div class="ui right">
<div class="ui jump dropdown">
<div class="ui primary tiny button">{{ctx.Locale.Tr "settings.authorized_integration.add"}}</div>
{{template "user/settings/authorized_integrations/link_menu" .}}
</div>
</div>
</h4>
<div class="ui attached segment">
<div class="flex-list">

View file

@ -0,0 +1,6 @@
<div class="menu">
<a class="item" href="./authorized-integrations/generic/new">
{{svg "octicon-cloud" 20}}
{{ctx.Locale.Tr "settings.authorized_integration.generic"}}
</a>
</div>

View file

@ -170,7 +170,13 @@
</div>
<div class="ui segment">
<button class="ui primary button">{{ctx.Locale.Tr "settings.authorized_integration.save"}}</button>
<button class="ui primary button">
{{if .IsNew}}
{{ctx.Locale.Tr "settings.authorized_integration.create"}}
{{else}}
{{ctx.Locale.Tr "settings.authorized_integration.save"}}
{{end}}
</button>
</div>

View file

@ -4,7 +4,11 @@
<form id="scoped-access-form" class="ui form" action="{{.Link}}" method="post">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.authorized_integration.edit_page_title" .Form.Name}}
{{if .IsNew}}
{{ctx.Locale.Tr "settings.authorized_integration.create_page_title"}}
{{else}}
{{ctx.Locale.Tr "settings.authorized_integration.edit_page_title" .Form.Name}}
{{end}}
</h4>
<div class="ui attached bottom segment">
<div class="required field {{if .Err_Name}}error{{end}}">
@ -17,11 +21,13 @@
<textarea id="description" name="description" rows="5" placeholder="{{ctx.Locale.Tr "settings.authorized_integration.field.description.placeholder"}}">{{.Form.Description}}</textarea>
</div>
<div class="field">
<label for="audience">{{ctx.Locale.Tr "settings.authorized_integration.field.audience"}}</label>
<div class="ui fluid action input">
<input id="audience" name="audience" value="{{.Form.Audience}}" readonly>
<button class="ui small icon button" title="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" aria-label="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" type="button" data-clipboard-target="#audience">{{svg "octicon-copy" 16}}</button>
{{if not .IsNew}}
<div class="field">
<label for="audience">{{ctx.Locale.Tr "settings.authorized_integration.field.audience"}}</label>
<div class="ui fluid action input">
<input id="audience" name="audience" value="{{.Form.Audience}}" readonly>
<button class="ui small icon button" title="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" aria-label="{{ctx.Locale.Tr "settings.authorized_integration.copy_audience"}}" type="button" data-clipboard-target="#audience">{{svg "octicon-copy" 16}}</button>
</div>
</div>
</div>
{{end}}
</div>

View file

@ -242,9 +242,11 @@ test('User: List authorized integrations', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await expect(page.locator('.flex-item-title')).toContainText('Example AI');
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
// Check for fixture data; check has to be safe for the presence of other authorized integrations
// created by previous test runs.
await expect(page.locator('.flex-item-title').filter({hasText: 'Example AI'})).not.toHaveCount(0);
await expect(page.locator('.flex-item-body').filter({hasText: 'Added on 2026-05-16'})).not.toHaveCount(0);
await expect(page.locator('.flex-item-body').filter({hasText: 'No recent activity'})).not.toHaveCount(0);
});
async function validateClaimRules(page: Page, expected: string) {
@ -255,11 +257,20 @@ async function validateClaimRules(page: Page, expected: string) {
await expect(page.locator('#claim_rules')).toHaveValue(expected);
}
async function editFixtureAuthorizedIntegration(page: Page) {
// When tests are run on multiple platforms, more than one authorized integration will be present from the "Add"
// tests that don't have a way to cleanup after themselves (no delete capability yet); find the right target
// to edit:
await page.locator('.flex-item')
.filter({has: page.locator('.flex-item-title', {hasText: 'Example AI'})})
.getByRole('link', {name: 'Edit'}).click();
}
test('User: View authorized integration', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('This is an authorized integration.\nThis example is just for viewing and editing.');
@ -285,7 +296,7 @@ test('User: Edit authorized integration basic fields', async ({browser}, workerI
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI (Updated!)');
await page.getByRole('textbox', {name: 'Description'}).fill('Updated by Edit authorized integration basic field test');
@ -294,12 +305,12 @@ test('User: Edit authorized integration basic fields', async ({browser}, workerI
// Returns to the list page; validate the updated name is present, and that it isn't marked
// as "used" just because it was edited:
await expect(page.locator('.flex-item-title')).toContainText('Example AI (Updated!)');
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
await expect(page.locator('.flex-item-title').filter({hasText: 'Example AI (Updated!)'})).not.toHaveCount(0);
await expect(page.locator('.flex-item-body').filter({hasText: 'Added on 2026-05-16'})).not.toHaveCount(0);
await expect(page.locator('.flex-item-body').filter({hasText: 'No recent activity'})).not.toHaveCount(0);
// Reopen to check description:
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI (Updated!)');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Updated by Edit authorized integration basic field test');
@ -307,14 +318,14 @@ test('User: Edit authorized integration basic fields', async ({browser}, workerI
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI');
await page.getByRole('textbox', {name: 'Description'}).fill('This is an authorized integration.\nThis example is just for viewing and editing.');
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
await expect(page.locator('.flex-item-title').filter({hasText: 'Example AI'})).not.toHaveCount(0); // ensure save completes and we land on list page
});
test('User: Edit authorized integration basic fields validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await page.getByRole('textbox', {name: 'Name'}).fill('\t'); // trims to empty
await page.getByRole('button', {name: 'Save authorized integration'}).click();
@ -326,7 +337,7 @@ test('User: Edit authorized integration issuer validation error', async ({browse
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('ftp://example.org'); // designed to hit "unsupported URL scheme" error, no external traffic involved
await page.getByRole('button', {name: 'Save authorized integration'}).click();
@ -338,7 +349,7 @@ test('User: Edit authorized integration claim rules', async ({browser}, workerIn
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
const editor = page.locator('.cm-content');
await editor.click(); // Focus codemirror editor
@ -349,7 +360,7 @@ test('User: Edit authorized integration claim rules', async ({browser}, workerIn
await page.getByRole('button', {name: 'Save authorized integration'}).click();
// Reopen to check claim rules saved:
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await validateClaimRules(page, '{\n "rules": [\n {\n "claim": "sub",\n "compare": "eq",\n "value": "a subject"\n }\n ]\n}');
// Restore values to avoid affecting other tests and other platforms:
@ -358,14 +369,14 @@ test('User: Edit authorized integration claim rules', async ({browser}, workerIn
await page.keyboard.press('Backspace'); // delete
await page.keyboard.type('{"rules": null}', {delay: 10});
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
await expect(page.locator('.flex-item-title').filter({hasText: 'Example AI'})).not.toHaveCount(0); // ensure save completes and we land on list page
});
test('User: Edit authorized integration claim rules validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
const editor = page.locator('.cm-content');
await editor.click(); // Focus codemirror editor
@ -379,7 +390,7 @@ test('User: Edit authorized integration specific repo', async ({browser}, worker
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
// clicking specific repositories will display currently available repositories:
await expect(page.getByText('org17/big_test_private_4')).toBeHidden();
@ -414,7 +425,7 @@ test('User: Edit authorized integration specific repo', async ({browser}, worker
await page.getByRole('button', {name: 'Save authorized integration'}).click();
// Reopen to check change to repo-specific was saved:
await page.getByRole('link', {name: 'Edit'}).click();
await editFixtureAuthorizedIntegration(page);
await expect(page.getByRole('radio', {name: 'All (public, private, and limited)'})).not.toBeChecked();
await expect(page.getByRole('radio', {name: 'Public only'})).not.toBeChecked();
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
@ -423,5 +434,60 @@ test('User: Edit authorized integration specific repo', async ({browser}, worker
// Restore values to avoid affecting other tests and other platforms:
await page.getByRole('radio', {name: 'All (public, private, and limited)'}).click();
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
await expect(page.locator('.flex-item-title').filter({hasText: 'Example AI'})).not.toHaveCount(0); // ensure save completes and we land on list page
});
test('User: Add authorized integration', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('');
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toBeHidden();
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'})).toHaveValue('');
await page.getByRole('textbox', {name: 'Name'}).fill('New Authorized Integration!');
await page.getByRole('textbox', {name: 'Description'}).fill('Description that carefully describes things.');
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('urn:forgejo:authorized-integrations:actions');
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
await page.getByRole('button', {name: 'Create authorized integration'}).click();
// Create will reload the page with a success banner, and the audience now populated:
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('New Authorized Integration!');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Description that carefully describes things.');
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toHaveValue(/^u:[0-9]+/);
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'})).toHaveValue('urn:forgejo:authorized-integrations:actions');
// Flash banner:
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
const flashText = await page.locator('.ui.message.flash-success').textContent();
expect(flashText?.trim()).toBe('Created authorized integration: New Authorized Integration!');
});
test('User: Add authorized integration validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('menu').filter({hasText: 'Add authorized integration'}).click();
await page.getByRole('menuitem', {name: 'Generic JWT Source'}).click();
await page.getByRole('textbox', {name: 'Name'}).fill('\t\t');
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('urn:forgejo:authorized-integrations:actions');
await page.getByRole('button', {name: 'Create authorized integration'}).click();
// Should have errors from having just whitespace in the Name field:
await expect(page.locator('.flash-error')).toContainText('Authorized integration name is required.');
await expect(page.getByRole('textbox', {name: 'Name'}).locator('..')).toHaveClass('required field error');
// Fill out missing field and resubmit:
await page.getByRole('textbox', {name: 'Name'}).fill('Forgot to fill this out!');
await page.getByRole('button', {name: 'Create authorized integration'}).click();
// Flash banner:
await expect(page.locator('.ui.message.flash-success')).toBeVisible();
const flashText = await page.locator('.ui.message.flash-success').textContent();
expect(flashText?.trim()).toBe('Created authorized integration: Forgot to fill this out!');
});