diff --git a/models/auth/authorized_integration.go b/models/auth/authorized_integration.go index 6079d28341..91415b626d 100644 --- a/models/auth/authorized_integration.go +++ b/models/auth/authorized_integration.go @@ -269,3 +269,29 @@ func ParseAuthorizedIntegrationUI(ui string) (AuthorizedIntegrationUI, error) { } return AuthorizedIntegrationUI(""), fmt.Errorf("invalid authorized integration UI: %q", ui) } + +// Delete an authorized integration by ID. Must only succeed if the authorized integration identified is owned by the +// user provided. +func DeleteAuthorizedIntegrationByID(ctx context.Context, id, userID int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + // Delete doesn't take into account userID, but will be rolled back by the transaction if the user ID isn't + // correct. Needs to occur first due to foreign key. + if err := db.DeleteBeans(ctx, + &AuthorizedIntegResourceRepo{IntegID: id}, + ); err != nil { + return fmt.Errorf("DeleteBeans: %w", err) + } + + cnt, err := db.GetEngine(ctx). + ID(id). + Delete(&AuthorizedIntegration{ + UserID: userID, + }) + if err != nil { + return err + } else if cnt != 1 { + return fmt.Errorf("authorized integration %d does not exist: %w", id, util.ErrNotExist) + } + return nil + }) +} diff --git a/models/auth/authorized_integration_test.go b/models/auth/authorized_integration_test.go index c77fb32cc6..e2581bff34 100644 --- a/models/auth/authorized_integration_test.go +++ b/models/auth/authorized_integration_test.go @@ -193,3 +193,37 @@ func TestUpdateAuthorizedIntegration(t *testing.T) { assert.Equal(t, createdUnix, fromDB.CreatedUnix) assert.Equal(t, updatedUnix, fromDB.UpdatedUnix) // not changed -- used to track usage for authentication } + +func TestDeleteAuthorizedIntegrationByID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Run("simple delete", func(t *testing.T) { + ai := makeAuthorizedIntegration(t) + err := auth_model.DeleteAuthorizedIntegrationByID(t.Context(), ai.ID, ai.UserID) + require.NoError(t, err) + unittest.AssertNotExistsBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}) + }) + + t.Run("delete repo-specific", func(t *testing.T) { + ai := makeAuthorizedIntegration(t) + resRepo1 := &auth_model.AuthorizedIntegResourceRepo{ + IntegID: ai.ID, + RepoID: 1, + } + err := auth_model.InsertAuthorizedIntegrationResourceRepos(t.Context(), ai.ID, + []*auth_model.AuthorizedIntegResourceRepo{resRepo1}) + require.NoError(t, err) + unittest.AssertCount(t, &auth_model.AuthorizedIntegResourceRepo{IntegID: ai.ID}, 1) + + err = auth_model.DeleteAuthorizedIntegrationByID(t.Context(), ai.ID, ai.UserID) + require.NoError(t, err) + + unittest.AssertNotExistsBean(t, &auth_model.AuthorizedIntegration{ID: ai.ID}) + unittest.AssertCount(t, &auth_model.AuthorizedIntegResourceRepo{IntegID: ai.ID}, 0) + }) + + t.Run("delete fails on wrong user", func(t *testing.T) { + ai := makeAuthorizedIntegration(t) + err := auth_model.DeleteAuthorizedIntegrationByID(t.Context(), ai.ID, 300) + require.ErrorIs(t, err, util.ErrNotExist) + }) +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 0fa86ac194..6e1b4d23dd 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -348,6 +348,9 @@ "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.", "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 45a1419034..f7e6cc7f96 100644 --- a/routers/web/user/setting/authorized_integrations.go +++ b/routers/web/user/setting/authorized_integrations.go @@ -442,3 +442,12 @@ func repoMultiSelect(ctx *context.Context) { pager.SetDefaultParams(ctx) ctx.Data["Page"] = pager } + +func DeleteAuthorizedIntegration(ctx *context.Context) { + if err := auth_model.DeleteAuthorizedIntegrationByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { + ctx.Flash.Error("DeleteAuthorizedIntegrationByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.authorized_integration.deleted")) + } + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/authorized-integrations") +} diff --git a/routers/web/web.go b/routers/web/web.go index b8d0c25ff9..bf9bb9be6e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -680,6 +680,7 @@ func registerRoutes(m *web.Route) { Get(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegration). Post(web.Bind(user_setting.AuthorizedIntegrationForm{}), user_setting.EditAuthorizedIntegrationPost) }) + m.Post("/delete", user_setting.DeleteAuthorizedIntegration) m.Get("", user_setting.ListAuthorizedIntegrations) }) diff --git a/templates/user/settings/authorized_integrations.tmpl b/templates/user/settings/authorized_integrations.tmpl index 9d27ae4261..b3c9700357 100644 --- a/templates/user/settings/authorized_integrations.tmpl +++ b/templates/user/settings/authorized_integrations.tmpl @@ -39,6 +39,10 @@ {{svg "octicon-pencil" 16 "tw-mr-1"}} {{ctx.Locale.Tr "settings.authorized_integration.edit"}} + {{else}} @@ -54,4 +58,15 @@ + + {{template "user/settings/layout_footer" .}} diff --git a/tests/e2e/user-settings.test.e2e.ts b/tests/e2e/user-settings.test.e2e.ts index 5247f5ea6e..caad5c0015 100644 --- a/tests/e2e/user-settings.test.e2e.ts +++ b/tests/e2e/user-settings.test.e2e.ts @@ -465,6 +465,16 @@ test('User: Add authorized integration', async ({browser}, workerInfo) => { 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!'); + + // Delete the added integration, minimizing left-over test data and also validating the delete UI: + await page.goto('/user/settings/authorized-integrations'); + await page.locator('.flex-item') + .filter({has: page.locator('.flex-item-title', {hasText: 'New Authorized Integration!'})}) + .getByRole('button', {name: 'Delete'}).click(); + await page.getByRole('button', {name: 'Yes'}).click(); + await expect(page.locator('.ui.message.flash-success')).toBeVisible(); + const deleteFlashText = await page.locator('.ui.message.flash-success').textContent(); + expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.'); }); test('User: Add authorized integration validation error', async ({browser}, workerInfo) => { @@ -490,4 +500,14 @@ test('User: Add authorized integration validation error', async ({browser}, work 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!'); + + // Delete the added integration, minimizing left-over test data and also validating the delete UI: + await page.goto('/user/settings/authorized-integrations'); + await page.locator('.flex-item') + .filter({has: page.locator('.flex-item-title', {hasText: 'Forgot to fill this out!'})}) + .getByRole('button', {name: 'Delete'}).click(); + await page.getByRole('button', {name: 'Yes'}).click(); + await expect(page.locator('.ui.message.flash-success')).toBeVisible(); + const deleteFlashText = await page.locator('.ui.message.flash-success').textContent(); + expect(deleteFlashText?.trim()).toBe('Authorized integration has been deleted successfully.'); });