refactor: replace legacy delete-button with link-action (#38143)

Removes the legacy `delete-button` handler (`initGlobalDeleteButton`)
and migrates all remaining usages to `link-action` and `show-modal` /
`form-fetch-action`.

Two handlers are adjusted for the new request shape: webauthn key delete
reads `id` from the query, and account deletion returns `JSONError` on
validation failure.

A E2E test ist added to cover one of the use cases.

Suggested in
https://github.com/go-gitea/gitea/pull/38046#discussion_r3414936737.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
silverwind
2026-06-18 14:02:11 +02:00
committed by GitHub
parent 64f3796567
commit de83393487
29 changed files with 108 additions and 195 deletions
+12 -29
View File
@@ -242,28 +242,16 @@ func DeleteAccount(ctx *context.Context) {
return return
} }
ctx.Data["Title"] = ctx.Tr("settings_title")
ctx.Data["PageIsSettingsAccount"] = true
ctx.Data["Email"] = ctx.Doer.Email
if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil { if _, _, err := auth.UserSignIn(ctx, ctx.Doer.Name, ctx.FormString("password")); err != nil {
switch { switch {
case user_model.IsErrUserNotExist(err): case user_model.IsErrUserNotExist(err):
loadAccountData(ctx) ctx.JSONError(ctx.Tr("form.user_not_exist"))
ctx.RenderWithErrDeprecated(ctx.Tr("form.user_not_exist"), tplSettingsAccount, nil)
case errors.Is(err, smtp.ErrUnsupportedLoginType): case errors.Is(err, smtp.ErrUnsupportedLoginType):
loadAccountData(ctx) ctx.JSONError(ctx.Tr("form.unsupported_login_type"))
ctx.RenderWithErrDeprecated(ctx.Tr("form.unsupported_login_type"), tplSettingsAccount, nil)
case errors.As(err, &db.ErrUserPasswordNotSet{}): case errors.As(err, &db.ErrUserPasswordNotSet{}):
loadAccountData(ctx) ctx.JSONError(ctx.Tr("form.unset_password"))
ctx.RenderWithErrDeprecated(ctx.Tr("form.unset_password"), tplSettingsAccount, nil)
case errors.As(err, &db.ErrUserPasswordInvalid{}): case errors.As(err, &db.ErrUserPasswordInvalid{}):
loadAccountData(ctx) ctx.JSONError(ctx.Tr("form.enterred_invalid_password"))
ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_password"), tplSettingsAccount, nil)
default: default:
ctx.ServerError("UserSignIn", err) ctx.ServerError("UserSignIn", err)
} }
@@ -272,32 +260,27 @@ func DeleteAccount(ctx *context.Context) {
// admin should not delete themself // admin should not delete themself
if ctx.Doer.IsAdmin { if ctx.Doer.IsAdmin {
ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self")) ctx.JSONError(ctx.Tr("form.admin_cannot_delete_self"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
return return
} }
if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil { if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil {
switch { switch {
case repo_model.IsErrUserOwnRepos(err): case repo_model.IsErrUserOwnRepos(err):
ctx.Flash.Error(ctx.Tr("form.still_own_repo")) ctx.JSONError(ctx.Tr("form.still_own_repo"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
case org_model.IsErrUserHasOrgs(err): case org_model.IsErrUserHasOrgs(err):
ctx.Flash.Error(ctx.Tr("form.still_has_org")) ctx.JSONError(ctx.Tr("form.still_has_org"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
case packages_model.IsErrUserOwnPackages(err): case packages_model.IsErrUserOwnPackages(err):
ctx.Flash.Error(ctx.Tr("form.still_own_packages")) ctx.JSONError(ctx.Tr("form.still_own_packages"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
case user_model.IsErrDeleteLastAdminUser(err): case user_model.IsErrDeleteLastAdminUser(err):
ctx.Flash.Error(ctx.Tr("auth.last_admin")) ctx.JSONError(ctx.Tr("auth.last_admin"))
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
default: default:
ctx.ServerError("DeleteUser", err) ctx.ServerError("DeleteUser", err)
} }
} else { return
log.Trace("Account deleted: %s", ctx.Doer.Name)
ctx.Redirect(setting.AppSubURL + "/")
} }
ctx.JSONRedirect(setting.AppSubURL + "/")
} }
func loadAccountData(ctx *context.Context) { func loadAccountData(ctx *context.Context) {
+14 -15
View File
@@ -247,17 +247,17 @@ func DeleteKey(ctx *context.Context) {
switch ctx.FormString("type") { switch ctx.FormString("type") {
case "gpg": case "gpg":
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
ctx.NotFound(errors.New("gpg keys setting is not allowed to be visited")) ctx.JSONError("gpg keys setting is not allowed to be visited")
return return
} }
if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeleteGPGKey: " + err.Error()) ctx.JSONError("Failed to delete PGP key")
} else { return
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
} }
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
case "ssh": case "ssh":
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
ctx.NotFound(errors.New("ssh keys setting is not allowed to be visited")) ctx.JSONError("ssh keys setting is not allowed to be visited")
return return
} }
@@ -268,24 +268,23 @@ func DeleteKey(ctx *context.Context) {
return return
} }
if external { if external {
ctx.Flash.Error(ctx.Tr("settings.ssh_externally_managed")) ctx.JSONError(ctx.Tr("settings.ssh_externally_managed"))
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
return return
} }
if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil { if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, keyID); err != nil {
ctx.Flash.Error("DeletePublicKey: " + err.Error()) ctx.JSONError("Failed to delete SSH key")
} else { return
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
} }
ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success"))
case "principal": case "principal":
if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
ctx.Flash.Error("DeletePublicKey: " + err.Error()) ctx.JSONError("Failed to delete SSH principal key")
} else { return
ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
} }
ctx.Flash.Success(ctx.Tr("settings.ssh_principal_deletion_success"))
default: default:
ctx.Flash.Warning("Function not implemented") ctx.JSONError("unsupported key type")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys") return
} }
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/keys") ctx.JSONRedirect(setting.AppSubURL + "/user/settings/keys")
} }
@@ -132,8 +132,7 @@ func WebauthnDelete(ctx *context.Context) {
return return
} }
form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) if _, err := auth.DeleteCredential(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil {
ctx.ServerError("GetWebAuthnCredentialByID", err) ctx.ServerError("GetWebAuthnCredentialByID", err)
return return
} }
+1 -1
View File
@@ -643,7 +643,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/webauthn", func() { m.Group("/webauthn", func() {
m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister) m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister)
m.Post("/register", security.WebauthnRegisterPost) m.Post("/register", security.WebauthnRegisterPost)
m.Post("/delete", web.Bind(forms.WebauthnDeleteForm{}), security.WebauthnDelete) m.Post("/delete", security.WebauthnDelete)
}) })
m.Group("/openid", func() { m.Group("/openid", func() {
m.Post("", web.Bind(forms.AddOpenIDForm{}), security.OpenIDPost) m.Post("", web.Bind(forms.AddOpenIDForm{}), security.OpenIDPost)
-11
View File
@@ -419,17 +419,6 @@ func (f *WebauthnRegistrationForm) Validate(req *http.Request, errs binding.Erro
return middleware.Validate(errs, ctx.Data, f, ctx.Locale) return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
} }
// WebauthnDeleteForm for deleting WebAuthn keys
type WebauthnDeleteForm struct {
ID int64 `binding:"Required"`
}
// Validate validates the fields
func (f *WebauthnDeleteForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// PackageSettingForm form for package settings // PackageSettingForm form for package settings
type PackageSettingForm struct { type PackageSettingForm struct {
Action string Action string
+7 -9
View File
@@ -34,12 +34,10 @@
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
{{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}} {{if and $.IsOrganizationOwner (not (and ($.Team.IsOwnerTeam) (eq (len $.Team.Members) 1)))}}
<form> <button class="ui red button show-modal" data-modal="#remove-team-member"
<button class="ui red button delete-button" data-modal-id="remove-team-member" data-modal-form.action="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove?uid={{.ID}}"
data-url="{{$.OrgLink}}/teams/{{$.Team.LowerName | PathEscape}}/action/remove" data-datauid="{{.ID}}" data-modal-name="{{.DisplayName}}"
data-name="{{.DisplayName}}" data-modal-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
data-data-team-name="{{$.Team.Name}}">{{ctx.Locale.Tr "org.members.remove"}}</button>
</form>
{{end}} {{end}}
</div> </div>
</div> </div>
@@ -74,13 +72,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="ui g-modal-confirm delete modal" id="remove-team-member"> <form class="ui small modal form-fetch-action" method="post" id="remove-team-member">
<div class="header"> <div class="header">
{{ctx.Locale.Tr "org.members.remove"}} {{ctx.Locale.Tr "org.members.remove"}}
</div> </div>
<div class="content"> <div class="content">
<p>{{ctx.Locale.Tr "org.members.remove.detail" (HTMLFormat `<span class="%s"></span>` "name") (HTMLFormat `<span class="%s"></span>` "dataTeamName")}}</p> <p>{{ctx.Locale.Tr "org.members.remove.detail" (HTMLFormat `<span class="%s"></span>` "name") (HTMLFormat `<span class="%s"></span>` "team-name")}}</p>
</div> </div>
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </form>
{{template "base/footer" .}} {{template "base/footer" .}}
+2 -2
View File
@@ -176,7 +176,7 @@
{{else}} {{else}}
<button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "org.teams.update_settings"}}</button>
{{if not .Team.IsOwnerTeam}} {{if not .Team.IsOwnerTeam}}
<button class="ui red button delete-button" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button> <button class="ui red button link-action" data-modal-confirm="#delete-team" data-url="{{.OrgLink}}/teams/{{.Team.Name | PathEscape}}/delete">{{ctx.Locale.Tr "org.teams.delete_team"}}</button>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@@ -187,7 +187,7 @@
</div> </div>
</div> </div>
<div class="ui g-modal-confirm delete modal"> <div class="ui small modal" id="delete-team">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "org.teams.delete_team_title"}} {{ctx.Locale.Tr "org.teams.delete_team_title"}}
+3 -3
View File
@@ -200,7 +200,7 @@
</span> </span>
</button> </button>
{{else}} {{else}}
<button class="btn interact-bg tw-p-2 delete-button delete-branch-button" data-url="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-name="{{.DBBranch.Name}}"> <button class="btn interact-bg tw-p-2 show-modal delete-branch-button tw-text-red" data-modal="#delete-branch-modal" data-modal-form.action="{{$.Link}}/delete?name={{.DBBranch.Name}}&page={{$.Page.Paginater.Current}}" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.delete" (.DBBranch.Name)}}" data-modal-name="{{.DBBranch.Name}}">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
</button> </button>
{{end}} {{end}}
@@ -216,7 +216,7 @@
</div> </div>
</div> </div>
<div class="ui g-modal-confirm delete modal"> <form class="ui small modal form-fetch-action" method="post" id="delete-branch-modal">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "repo.branch.delete_html"}} <span class="name"></span> {{ctx.Locale.Tr "repo.branch.delete_html"}} <span class="name"></span>
@@ -225,7 +225,7 @@
<p>{{ctx.Locale.Tr "repo.branch.delete_desc"}}</p> <p>{{ctx.Locale.Tr "repo.branch.delete_desc"}}</p>
</div> </div>
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </form>
<div class="ui mini modal" id="create-branch-modal"> <div class="ui mini modal" id="create-branch-modal">
<div class="header"> <div class="header">
+2 -2
View File
@@ -52,7 +52,7 @@
<button type="button" class="ui button link-action" data-url="{{.Link}}/update-runner?disabled={{not .Runner.IsDisabled}}"> <button type="button" class="ui button link-action" data-url="{{.Link}}/update-runner?disabled={{not .Runner.IsDisabled}}">
{{if .Runner.IsDisabled}}{{ctx.Locale.Tr "actions.runners.enable_runner"}}{{else}}{{ctx.Locale.Tr "actions.runners.disable_runner"}}{{end}} {{if .Runner.IsDisabled}}{{ctx.Locale.Tr "actions.runners.enable_runner"}}{{else}}{{ctx.Locale.Tr "actions.runners.disable_runner"}}{{end}}
</button> </button>
<button class="ui red button delete-button" data-url="{{.Link}}/delete" data-modal="#runner-delete-modal"> <button class="ui red button link-action" data-url="{{.Link}}/delete" data-modal-confirm="#runner-delete-modal">
{{ctx.Locale.Tr "actions.runners.delete_runner"}}</button> {{ctx.Locale.Tr "actions.runners.delete_runner"}}</button>
</div> </div>
</form> </form>
@@ -95,7 +95,7 @@
</table> </table>
{{template "base/paginate" .}} {{template "base/paginate" .}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="runner-delete-modal"> <div class="ui small modal" id="runner-delete-modal">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "actions.runners.delete_runner_header"}} {{ctx.Locale.Tr "actions.runners.delete_runner_header"}}
+5 -5
View File
@@ -56,7 +56,7 @@
</div> </div>
<div class="flex-text-block"> <div class="flex-text-block">
{{if not .IsPrimary}} {{if not .IsPrimary}}
<button class="ui red tiny button delete-button" data-modal-id="delete-email" data-url="{{AppSubUrl}}/user/settings/account/email/delete" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-email" data-url="{{AppSubUrl}}/user/settings/account/email/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_email"}} {{ctx.Locale.Tr "settings.delete_email"}}
</button> </button>
{{if .CanBePrimary}} {{if .CanBePrimary}}
@@ -115,19 +115,19 @@
<p class="text left tw-font-semibold">{{ctx.Locale.Tr "settings.delete_with_all_comments" .UserDeleteWithCommentsMaxTime}}</p> <p class="text left tw-font-semibold">{{ctx.Locale.Tr "settings.delete_with_all_comments" .UserDeleteWithCommentsMaxTime}}</p>
{{end}} {{end}}
</div> </div>
<form class="ui form ignore-dirty" id="delete-form" action="{{AppSubUrl}}/user/settings/account/delete" method="post"> <form class="ui form ignore-dirty form-fetch-action" action="{{AppSubUrl}}/user/settings/account/delete" method="post">
{{template "base/disable_form_autofill"}} {{template "base/disable_form_autofill"}}
<div class="required field {{if .Err_Password}}error{{end}}"> <div class="required field {{if .Err_Password}}error{{end}}">
<label for="password-confirmation">{{ctx.Locale.Tr "password"}}</label> <label for="password-confirmation">{{ctx.Locale.Tr "password"}}</label>
<input id="password-confirmation" name="password" type="password" autocomplete="off" required> <input id="password-confirmation" name="password" type="password" autocomplete="off" required>
</div> </div>
<div class="field"> <div class="field">
<button class="ui red button delete-button" data-modal-id="delete-account" data-type="form" data-form="#delete-form"> <button class="ui red button" data-modal-confirm="#delete-account">
{{ctx.Locale.Tr "settings.confirm_delete_account"}} {{ctx.Locale.Tr "settings.confirm_delete_account"}}
</button> </button>
</div> </div>
</form> </form>
<div class="ui g-modal-confirm delete modal" id="delete-account"> <div class="ui small modal" id="delete-account">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_account_title"}} {{ctx.Locale.Tr "settings.delete_account_title"}}
@@ -141,7 +141,7 @@
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-email"> <div class="ui small modal" id="delete-email">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.email_deletion"}} {{ctx.Locale.Tr "settings.email_deletion"}}
+2 -2
View File
@@ -40,7 +40,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-token" data-url="{{$.Link}}/delete" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-token" data-url="{{$.Link}}/delete?id={{.ID}}">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_token"}} {{ctx.Locale.Tr "settings.delete_token"}}
</button> </button>
@@ -92,7 +92,7 @@
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-token"> <div class="ui small modal" id="delete-token">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.access_token_deletion"}} {{ctx.Locale.Tr "settings.access_token_deletion"}}
@@ -24,7 +24,7 @@
{{svg "octicon-pencil"}} {{svg "octicon-pencil"}}
{{ctx.Locale.Tr "settings.oauth2_application_edit"}} {{ctx.Locale.Tr "settings.oauth2_application_edit"}}
</a> </a>
<button class="ui red tiny button delete-button" data-modal-id="remove-gitea-oauth2-application" <button class="ui red tiny button link-action" data-modal-confirm="#remove-gitea-oauth2-application"
data-url="{{$.Link}}/oauth2/{{.ID}}/delete"> data-url="{{$.Link}}/oauth2/{{.ID}}/delete">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
@@ -35,7 +35,7 @@
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="remove-gitea-oauth2-application"> <div class="ui small modal" id="remove-gitea-oauth2-application">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.remove_oauth2_application"}} {{ctx.Locale.Tr "settings.remove_oauth2_application"}}
+2 -2
View File
@@ -18,7 +18,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="revoke-gitea-oauth2-grant" <button class="ui red tiny button link-action" data-modal-confirm="#revoke-gitea-oauth2-grant"
data-url="{{AppSubUrl}}/user/settings/applications/oauth2/{{.ApplicationID}}/revoke/{{.ID}}"> data-url="{{AppSubUrl}}/user/settings/applications/oauth2/{{.ApplicationID}}/revoke/{{.ID}}">
{{ctx.Locale.Tr "settings.revoke_key"}} {{ctx.Locale.Tr "settings.revoke_key"}}
</button> </button>
@@ -27,7 +27,7 @@
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="revoke-gitea-oauth2-grant"> <div class="ui small modal" id="revoke-gitea-oauth2-grant">
<div class="header"> <div class="header">
{{svg "octicon-shield" 16 "tw-mr-1"}} {{svg "octicon-shield" 16 "tw-mr-1"}}
{{ctx.Locale.Tr "settings.revoke_oauth2_grant"}} {{ctx.Locale.Tr "settings.revoke_oauth2_grant"}}
+2 -2
View File
@@ -68,7 +68,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-gpg" data-url="{{$.Link}}/delete?type=gpg" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-gpg" data-url="{{$.Link}}/delete?type=gpg&id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
{{if and (not .Verified) (ne $.VerifyingID .KeyID)}} {{if and (not .Verified) (ne $.VerifyingID .KeyID)}}
@@ -108,7 +108,7 @@
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-gpg"> <div class="ui small modal" id="delete-gpg">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.gpg_key_deletion"}} {{ctx.Locale.Tr "settings.gpg_key_deletion"}}
+2 -2
View File
@@ -26,7 +26,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-principal" data-url="{{$.Link}}/delete?type=principal" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-principal" data-url="{{$.Link}}/delete?type=principal&id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
</div> </div>
@@ -55,7 +55,7 @@
</div> </div>
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-principal"> <div class="ui small modal" id="delete-principal">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.ssh_principal_deletion"}} {{ctx.Locale.Tr "settings.ssh_principal_deletion"}}
+2 -2
View File
@@ -56,7 +56,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button{{if index $.ExternalKeys $index}} disabled{{end}}" data-modal-id="delete-ssh" data-url="{{$.Link}}/delete?type=ssh" data-id="{{.ID}}"{{if index $.ExternalKeys $index}} title="{{ctx.Locale.Tr "settings.ssh_externally_managed"}}"{{end}}> <button class="ui red tiny button link-action{{if index $.ExternalKeys $index}} disabled{{end}}" data-modal-confirm="#delete-ssh" data-url="{{$.Link}}/delete?type=ssh&id={{.ID}}"{{if index $.ExternalKeys $index}} title="{{ctx.Locale.Tr "settings.ssh_externally_managed"}}"{{end}}>
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
{{if and (not .Verified) (ne $.VerifyingFingerprint .Fingerprint)}} {{if and (not .Verified) (ne $.VerifyingFingerprint .Fingerprint)}}
@@ -104,7 +104,7 @@
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-ssh"> <div class="ui small modal" id="delete-ssh">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.ssh_key_deletion"}} {{ctx.Locale.Tr "settings.ssh_key_deletion"}}
+7 -10
View File
@@ -23,13 +23,10 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<form> <button class="ui red button show-modal" data-modal="#leave-organization"
<button class="ui red button delete-button" data-modal-id="leave-organization" data-modal-form.action="{{.OrganisationLink}}/members/action/leave?uid={{$.SignedUser.ID}}"
data-url="{{.OrganisationLink}}/members/action/leave" data-datauid="{{$.SignedUser.ID}}" data-modal-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
data-name="{{$.SignedUser.DisplayName}}" </button>
data-data-organization-name="{{.DisplayName}}">{{ctx.Locale.Tr "org.members.leave"}}
</button>
</form>
</div> </div>
</div> </div>
{{end}} {{end}}
@@ -41,14 +38,14 @@
</div> </div>
</div> </div>
<div class="ui g-modal-confirm delete modal" id="leave-organization"> <form class="ui small modal form-fetch-action" method="post" id="leave-organization">
<div class="header"> <div class="header">
{{ctx.Locale.Tr "org.members.leave"}} {{ctx.Locale.Tr "org.members.leave"}}
</div> </div>
<div class="content"> <div class="content">
<p>{{ctx.Locale.Tr "org.members.leave.detail" (HTMLFormat `<span class="%s"></span>` "dataOrganizationName")}}</p> <p>{{ctx.Locale.Tr "org.members.leave.detail" (HTMLFormat `<span class="%s"></span>` "organization-name")}}</p>
</div> </div>
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </form>
{{template "user/settings/layout_footer" .}} {{template "user/settings/layout_footer" .}}
@@ -40,7 +40,7 @@
{{end}} {{end}}
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-account-link" data-url="{{AppSubUrl}}/user/settings/security/account_link" data-id="{{$loginSource.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-account-link" data-url="{{AppSubUrl}}/user/settings/security/account_link?id={{$loginSource.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
</div> </div>
@@ -48,7 +48,7 @@
{{end}} {{end}}
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-account-link"> <div class="ui small modal" id="delete-account-link">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.remove_account_link"}} {{ctx.Locale.Tr "settings.remove_account_link"}}
+2 -2
View File
@@ -29,7 +29,7 @@
</button> </button>
{{end}} {{end}}
</form> </form>
<button class="ui red tiny button delete-button" data-modal-id="delete-openid" data-url="{{AppSubUrl}}/user/settings/security/openid/delete" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-openid" data-url="{{AppSubUrl}}/user/settings/security/openid/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
</div> </div>
@@ -48,7 +48,7 @@
</button> </button>
</form> </form>
<div class="ui g-modal-confirm delete modal" id="delete-openid"> <div class="ui small modal" id="delete-openid">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.openid_deletion"}} {{ctx.Locale.Tr "settings.openid_deletion"}}
+3 -3
View File
@@ -9,9 +9,9 @@
<p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p> <p>{{ctx.Locale.Tr "settings.regenerate_scratch_token_desc"}}</p>
<button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "settings.twofa_scratch_token_regenerate"}}</button>
</form> </form>
<form class="ui form" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post" enctype="multipart/form-data" id="disable-form"> <form class="ui form form-fetch-action" action="{{AppSubUrl}}/user/settings/security/two_factor/disable" method="post">
<p>{{ctx.Locale.Tr "settings.twofa_disable_note"}}</p> <p>{{ctx.Locale.Tr "settings.twofa_disable_note"}}</p>
<button class="ui red button delete-button" data-modal-id="disable-twofa" data-type="form" data-form="#disable-form">{{ctx.Locale.Tr "settings.twofa_disable"}}</button> <button class="ui red button" data-modal-confirm="#disable-twofa">{{ctx.Locale.Tr "settings.twofa_disable"}}</button>
</form> </form>
{{else}} {{else}}
{{/* The recovery tip is there as a means of encouraging a user to enroll */}} {{/* The recovery tip is there as a means of encouraging a user to enroll */}}
@@ -22,7 +22,7 @@
</div> </div>
{{end}} {{end}}
<div class="ui g-modal-confirm delete modal" id="disable-twofa"> <div class="ui small modal" id="disable-twofa">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.twofa_disable"}} {{ctx.Locale.Tr "settings.twofa_disable"}}
@@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<div class="item-trailing"> <div class="item-trailing">
<button class="ui red tiny button delete-button" data-modal-id="delete-registration" data-url="{{$.Link}}/webauthn/delete" data-id="{{.ID}}"> <button class="ui red tiny button link-action" data-modal-confirm="#delete-registration" data-url="{{$.Link}}/webauthn/delete?id={{.ID}}">
{{ctx.Locale.Tr "settings.delete_key"}} {{ctx.Locale.Tr "settings.delete_key"}}
</button> </button>
</div> </div>
@@ -30,7 +30,7 @@
</div> </div>
<button id="register-webauthn" class="ui primary button">{{svg "octicon-key"}} {{ctx.Locale.Tr "settings.webauthn_register_key"}}</button> <button id="register-webauthn" class="ui primary button">{{svg "octicon-key"}} {{ctx.Locale.Tr "settings.webauthn_register_key"}}</button>
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-registration"> <div class="ui small modal" id="delete-registration">
<div class="header"> <div class="header">
{{svg "octicon-trash"}} {{svg "octicon-trash"}}
{{ctx.Locale.Tr "settings.webauthn_delete_key"}} {{ctx.Locale.Tr "settings.webauthn_delete_key"}}
+18
View File
@@ -32,3 +32,21 @@ test('add team member search', async ({page, request}) => {
const result = page.locator('#search-user-box .results .result').first(); const result = page.locator('#search-user-box .results .result').first();
await expect(result).toContainText(userName); await expect(result).toContainText(userName);
}); });
test('delete team via confirm modal', async ({page, request}) => {
const orgName = `e2e-del-team-${randomString(8)}`;
const teamName = `team-${randomString(8)}`;
await Promise.all([
(async () => {
await apiCreateOrg(request, orgName);
await apiCreateTeam(request, orgName, teamName);
})(),
login(page),
]);
await page.goto(`/org/${orgName}/teams/${teamName}/edit`);
await page.getByRole('button', {name: 'Delete Team'}).click();
await page.getByRole('button', {name: 'Yes'}).click();
await expect(page).toHaveURL(new RegExp(`/org/${orgName}/teams$`));
await expect(page.getByText('The team has been deleted.')).toBeVisible();
});
+4 -4
View File
@@ -38,13 +38,13 @@ func TestViewBranches(t *testing.T) {
} }
func TestUndoDeleteBranch(t *testing.T) { func TestUndoDeleteBranch(t *testing.T) {
branchAction := func(t *testing.T, button string) (*HTMLDoc, string) { branchAction := func(t *testing.T, button, attr string) (*HTMLDoc, string) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
req := NewRequest(t, "GET", "/user2/repo1/branches") req := NewRequest(t, "GET", "/user2/repo1/branches")
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body) htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find(button).Attr("data-url") link, exists := htmlDoc.doc.Find(button).Attr(attr)
require.True(t, exists, "The template has changed") require.True(t, exists, "The template has changed")
linkURL, err := url.Parse(link) linkURL, err := url.Parse(link)
require.NoError(t, err) require.NoError(t, err)
@@ -58,12 +58,12 @@ func TestUndoDeleteBranch(t *testing.T) {
} }
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
htmlDoc, name := branchAction(t, ".delete-branch-button") htmlDoc, name := branchAction(t, ".delete-branch-button", "data-modal-form.action")
assert.Contains(t, assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(), htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name), translation.NewLocale("en-US").TrString("repo.branch.deletion_success", name),
) )
htmlDoc, name = branchAction(t, ".restore-branch-button") htmlDoc, name = branchAction(t, ".restore-branch-button", "data-url")
assert.Contains(t, assert.Contains(t,
htmlDoc.doc.Find(".ui.positive.message").Text(), htmlDoc.doc.Find(".ui.positive.message").Text(),
translation.NewLocale("en-US").TrString("repo.branch.restore_success", name), translation.NewLocale("en-US").TrString("repo.branch.restore_success", name),
+7 -3
View File
@@ -13,7 +13,10 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/test"
"gitea.dev/tests" "gitea.dev/tests"
"github.com/stretchr/testify/assert"
) )
func assertUserDeleted(t *testing.T, userID int64) { func assertUserDeleted(t *testing.T, userID int64) {
@@ -34,7 +37,8 @@ func TestUserDeleteAccount(t *testing.T) {
session := loginUser(t, "user8") session := loginUser(t, "user8")
urlStr := "/user/settings/account/delete?password=" + userPassword urlStr := "/user/settings/account/delete?password=" + userPassword
req := NewRequest(t, "POST", urlStr) req := NewRequest(t, "POST", urlStr)
session.MakeRequest(t, req, http.StatusSeeOther) resp := session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect)
assertUserDeleted(t, 8) assertUserDeleted(t, 8)
unittest.CheckConsistencyFor(t, &user_model.User{}) unittest.CheckConsistencyFor(t, &user_model.User{})
@@ -46,8 +50,8 @@ func TestUserDeleteAccountStillOwnRepos(t *testing.T) {
session := loginUser(t, "user2") session := loginUser(t, "user2")
urlStr := "/user/settings/account/delete?password=" + userPassword urlStr := "/user/settings/account/delete?password=" + userPassword
req := NewRequest(t, "POST", urlStr) req := NewRequest(t, "POST", urlStr)
session.MakeRequest(t, req, http.StatusSeeOther) resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.NotEmpty(t, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
// user should not have been deleted, because the user still owns repos // user should not have been deleted, because the user still owns repos
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
} }
+1
View File
@@ -41,6 +41,7 @@ func (doc *HTMLDoc) Find(selector string) *goquery.Selection {
// AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists // AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists
func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) { func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) {
t.Helper()
sel := doc.doc.Find(selector) sel := doc.doc.Find(selector)
switch v := any(checkExists).(type) { switch v := any(checkExists).(type) {
case bool: case bool:
+2 -2
View File
@@ -70,7 +70,7 @@ func TestUserSettingsAccount(t *testing.T) {
AssertHTMLElement(t, doc, "#password", true) AssertHTMLElement(t, doc, "#password", true)
AssertHTMLElement(t, doc, "#email", true) AssertHTMLElement(t, doc, "#email", true)
AssertHTMLElement(t, doc, "#delete-form", true) AssertHTMLElement(t, doc, `form[action="/user/settings/account/delete"]`, true)
}) })
t.Run("credentials disabled", func(t *testing.T) { t.Run("credentials disabled", func(t *testing.T) {
@@ -87,7 +87,7 @@ func TestUserSettingsAccount(t *testing.T) {
AssertHTMLElement(t, doc, "#password", false) AssertHTMLElement(t, doc, "#password", false)
AssertHTMLElement(t, doc, "#email", false) AssertHTMLElement(t, doc, "#email", false)
AssertHTMLElement(t, doc, "#delete-form", true) AssertHTMLElement(t, doc, `form[action="/user/settings/account/delete"]`, true)
}) })
t.Run("deletion disabled", func(t *testing.T) { t.Run("deletion disabled", func(t *testing.T) {
-5
View File
@@ -326,11 +326,6 @@
margin: 0 0.25em 0 0; margin: 0 0.25em 0 0;
} }
.delete-button,
.delete-button:hover {
color: var(--color-red);
}
/* btn is a plain button without any opinionated styling, it only uses flex for vertical alignment like ".ui.button" in base.css */ /* btn is a plain button without any opinionated styling, it only uses flex for vertical alignment like ".ui.button" in base.css */
.btn { .btn {
-69
View File
@@ -1,4 +1,3 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts'; import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts'; import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue'; import {camelize} from 'vue';
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
}); });
} }
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value ?? null;
}
}
showFomanticModal(modal, {
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
form.classList.add('is-loading');
form.submit();
return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
})();
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
return false; // prevent modal from closing automatically
},
});
});
}
}
function onShowPanelClick(el: HTMLElement, e: MouseEvent) { function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"` // a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel // if it has "toggle" class, it toggles the panel
+1 -2
View File
@@ -58,7 +58,7 @@ import {initAdminSelfCheck} from './features/admin/selfcheck.ts';
import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts'; import {initOAuth2SettingsDisableCheckbox} from './features/oauth2-settings.ts';
import {initGlobalFetchAction} from './features/common-fetch-action.ts'; import {initGlobalFetchAction} from './features/common-fetch-action.ts';
import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts'; import {initCommmPageComponents, initGlobalComponent, initGlobalDropdown, initGlobalInput} from './features/common-page.ts';
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; import {initGlobalButtonClickOnEnter, initGlobalButtons} from './features/common-button.ts';
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts'; import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
@@ -81,7 +81,6 @@ const initPerformanceTracer = callInitFunctions([
initGlobalEnterQuickSubmit, initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm, initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor, initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initGlobalInput, initGlobalInput,
initGlobalShortcut, initGlobalShortcut,