diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index bc376bcc435..47e946a37fe 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3052,6 +3052,8 @@ "admin.users.new_success": "The user account \"%s\" has been created.", "admin.users.edit": "Edit", "admin.users.impersonate": "Impersonate", + "admin.users.impersonate_stop": "Stop impersonating", + "admin.users.impersonating_notice": "You are impersonating %s. Actions you take are performed as this user.", "admin.users.auth_source": "Authentication Source", "admin.users.local": "Local", "admin.users.auth_login_name": "Authentication Sign-In Name", diff --git a/routers/web/home.go b/routers/web/home.go index 7f1491a41d4..38496aaa28a 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -38,7 +38,7 @@ func Home(ctx *context.Context) { log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login") - } else if ctx.Doer.MustChangePassword { + } else if doerMustChangePassword(ctx) { ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) diff --git a/routers/web/web.go b/routers/web/web.go index 59f85166470..211c03b2c1f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -166,6 +166,11 @@ func newWebAuthMiddleware() *AuthMiddleware { return webAuth } +func doerMustChangePassword(ctx *context.Context) bool { + // an impersonating admin must not be forced to set the impersonated user's password + return ctx.Doer != nil && ctx.Doer.MustChangePassword && !ctx.DoerIsImpersonated() +} + // verifyAuthWithOptions checks authentication according to options func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Context) { crossOriginProtection := http.NewCrossOriginProtection() @@ -185,7 +190,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont return } - if ctx.Doer.MustChangePassword { + if doerMustChangePassword(ctx) { if ctx.Req.URL.Path != "/user/settings/change_password" { if strings.HasPrefix(ctx.Req.UserAgent(), "git") { ctx.HTTPError(http.StatusUnauthorized, ctx.Locale.TrString("auth.must_change_password")) diff --git a/services/context/context.go b/services/context/context.go index 8bbe0795edf..6f4cf63cfa7 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -228,6 +228,11 @@ func (ctx *Context) DoerNeedTwoFactorAuth() bool { return ctx.Session.Get(session.KeyUserHasTwoFactorAuth) == false } +// DoerIsImpersonated returns true if the current session is an admin impersonating the doer +func (ctx *Context) DoerIsImpersonated() bool { + return ctx.Session.Get(session.KeyImpersonatorData) != nil +} + // HasError returns true if error occurs in form validation. // Attention: this function changes ctx.Data and ctx.Flash // If HasError is called, then before Redirect, the error message should be stored by ctx.Flash.Error(ctx.GetErrMsg()) again. diff --git a/services/context/context_template.go b/services/context/context_template.go index b1c213ca9e1..b4775171eb9 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -12,6 +12,7 @@ import ( "strings" "time" + user_model "gitea.dev/models/user" "gitea.dev/modules/htmlutil" "gitea.dev/modules/httplib" "gitea.dev/modules/public" @@ -66,6 +67,14 @@ func (c TemplateContext) CurrentWebTheme() *webtheme.ThemeMetaInfo { return webtheme.GuaranteeGetThemeMetaInfo(themeName) } +func (c TemplateContext) ImpersonatedUser() *user_model.User { + webCtx := GetWebContext(c) + if webCtx == nil || webCtx.Doer == nil || !webCtx.DoerIsImpersonated() { + return nil + } + return webCtx.Doer +} + func (c TemplateContext) CurrentWebBanner() *setting.WebBannerType { // Using revision as a simple approach to determine if the banner has been changed after the user dismissed it. // There could be some false-positives because revision can be changed even if the banner isn't. diff --git a/templates/admin/config_settings/instance.tmpl b/templates/admin/config_settings/instance.tmpl index da28fffddb4..c6203aca4a3 100644 --- a/templates/admin/config_settings/instance.tmpl +++ b/templates/admin/config_settings/instance.tmpl @@ -37,7 +37,7 @@ {{template "shared/combomarkdowneditor" (dict - "ContainerClasses" "web-banner-content-editor" + "ContainerClasses" "site-banner-content-editor" "TextareaName" (print $cfgKey ".ContentMessage") "TextareaContent" $banner.ContentMessage "TextareaPlaceholder" (ctx.Locale.Tr "admin.config.instance_web_banner.message_placeholder") diff --git a/templates/base/head_banner.tmpl b/templates/base/head_banner.tmpl index d237161622a..d8b95cd8daa 100644 --- a/templates/base/head_banner.tmpl +++ b/templates/base/head_banner.tmpl @@ -1,10 +1,10 @@ {{$banner := ctx.CurrentWebBanner}} {{if $banner}} -
-
+
+
{{ctx.RenderUtils.MarkdownToHtml $banner.ContentMessage}}
-
diff --git a/templates/base/head_impersonate_banner.tmpl b/templates/base/head_impersonate_banner.tmpl new file mode 100644 index 00000000000..921ec1a0601 --- /dev/null +++ b/templates/base/head_impersonate_banner.tmpl @@ -0,0 +1,10 @@ +{{$impersonated := ctx.ImpersonatedUser}} +{{if $impersonated}} +
+
+ {{svg "octicon-alert"}} + {{ctx.Locale.Tr "admin.users.impersonating_notice" $impersonated.Name}} + {{ctx.Locale.Tr "admin.users.impersonate_stop"}} +
+
+{{end}} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 348f236fdc8..0da87a09401 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -178,4 +178,5 @@
+{{template "base/head_impersonate_banner"}} {{template "base/head_banner"}} diff --git a/templates/devtest/severity-colors.tmpl b/templates/devtest/severity-colors.tmpl index 43a51614655..efd7369301a 100644 --- a/templates/devtest/severity-colors.tmpl +++ b/templates/devtest/severity-colors.tmpl @@ -59,7 +59,7 @@
Warning section body content.

Banner Preview (info-tinted)

-
+
Banner preview content
diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go index 5c113e97aef..bb89ef91d96 100644 --- a/tests/integration/admin_user_test.go +++ b/tests/integration/admin_user_test.go @@ -14,6 +14,7 @@ import ( "gitea.dev/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAdminViewUsers(t *testing.T) { @@ -106,29 +107,41 @@ func TestAdminDeleteUser(t *testing.T) { func TestAdminImpersonatedUser(t *testing.T) { defer tests.PrepareTestEnv(t)() + // user2 never signed in yet, only the user themselves should be asked to set a password + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user2.MustChangePassword = true + require.NoError(t, user_model.UpdateUserCols(t.Context(), user2, "must_change_password")) + session := loginUser(t, "user1") - currentUsername := func(t *testing.T) string { + homeDoc := func(t *testing.T) *HTMLDoc { t.Helper() resp := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK) - doc := NewHTMLParser(t, resp.Body) + return NewHTMLParser(t, resp.Body) + } + currentUsername := func(doc *HTMLDoc) string { return doc.Find("[data-signed-in-username]").AttrOr("data-signed-in-username", "") } // user1 is admin, can visit admin pages - assert.Equal(t, "user1", currentUsername(t)) + assert.Equal(t, "user1", currentUsername(homeDoc(t))) + assert.Equal(t, 0, homeDoc(t).Find(".site-banner-container").Length()) session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/users/2"), http.StatusOK) // impersonate to user2, user2 can't visit admin pages session.MakeRequest(t, NewRequest(t, "POST", "/-/admin/users/2/impersonate"), http.StatusOK) - assert.Equal(t, "user2", currentUsername(t)) + doc := homeDoc(t) + assert.Equal(t, "user2", currentUsername(doc)) + assert.Contains(t, doc.Find(".site-banner-container").Text(), "user2") session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/users/2"), http.StatusForbidden) + // the impersonating admin must not set the password of the impersonated user + session.MakeRequest(t, NewRequest(t, "GET", "/user/settings/change_password"), http.StatusSeeOther) // exit impersonation, current user is user1(admin) again session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther) - assert.Equal(t, "user1", currentUsername(t)) + assert.Equal(t, "user1", currentUsername(homeDoc(t))) session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/users/2"), http.StatusOK) // completely logout session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther) - assert.Equal(t, "", currentUsername(t)) + assert.Equal(t, "", currentUsername(homeDoc(t))) } diff --git a/web_src/css/admin.css b/web_src/css/admin.css index 3d5ca60180f..d9ff1b20c82 100644 --- a/web_src/css/admin.css +++ b/web_src/css/admin.css @@ -44,7 +44,7 @@ margin-bottom: 1rem; } -.web-banner-content-editor .render-content.render-preview { +.site-banner-content-editor .render-content.render-preview { /* use the styles from ".ui.message" */ padding: 1em 1.5em; border: 1px solid var(--color-info-border); diff --git a/web_src/css/modules/button.css b/web_src/css/modules/button.css index 9bc60fd9377..a13756c14bd 100644 --- a/web_src/css/modules/button.css +++ b/web_src/css/modules/button.css @@ -32,6 +32,12 @@ color: var(--color-text); } +.ui.button.hover-opaque:hover { + /* ".ui.button:hover" uses alpha channel to "highlight", it doesn't work when the background is not body: the inherited color is not right + for such case, use this opaque color */ + background: var(--color-hover-opaque); +} + .ui.active.button, .ui.button:active, .ui.active.button:active, diff --git a/web_src/css/modules/container.css b/web_src/css/modules/container.css index 1b2a1d64b76..6c54d8dc518 100644 --- a/web_src/css/modules/container.css +++ b/web_src/css/modules/container.css @@ -15,19 +15,24 @@ width: 800px; } -.ui.message.web-banner-container { +.ui.message.site-banner-container { position: relative; margin: 0; border-radius: 0; } -.ui.message.web-banner-container > .web-banner-content { +#navbar + .ui.message.site-banner-container, +.ui.message.site-banner-container + .ui.message.site-banner-container { + border-top: none; +} + +.ui.message.site-banner-container > .site-banner-content { width: 1280px; max-width: calc(100% - calc(2 * var(--page-margin-x))); margin: auto; } -.ui.message.web-banner-container > button.dismiss-banner { +.ui.message.site-banner-container > button.site-banner-close { position: absolute; right: 20px; top: 15px; diff --git a/web_src/css/modules/navbar.css b/web_src/css/modules/navbar.css index 02d6e7be107..d548d0148f5 100644 --- a/web_src/css/modules/navbar.css +++ b/web_src/css/modules/navbar.css @@ -7,11 +7,6 @@ padding: 0 10px; } -/* When notification message is present after navbar, hide border to avoid double border */ -#navbar:has(+ .ui.message) { - border-bottom: none; -} - #navbar .navbar-left, #navbar .navbar-right { display: flex;