mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-01 13:55:07 +02:00
feat: admin impersonates a user (#38614)
* fix #3631 * fix #21599 by the way, refactored the "profile avatar card" to simplify the code.
This commit is contained in:
@@ -4,8 +4,9 @@
|
||||
package session
|
||||
|
||||
const (
|
||||
KeyUID = "uid"
|
||||
KeyUname = "uname"
|
||||
KeyUID = "uid"
|
||||
|
||||
KeyImpersonatorData = "impersonatorData"
|
||||
|
||||
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
|
||||
)
|
||||
|
||||
@@ -3038,6 +3038,7 @@
|
||||
"admin.users.send_register_notify": "Send User Registration Notification",
|
||||
"admin.users.new_success": "The user account \"%s\" has been created.",
|
||||
"admin.users.edit": "Edit",
|
||||
"admin.users.impersonate": "Impersonate",
|
||||
"admin.users.auth_source": "Authentication Source",
|
||||
"admin.users.local": "Local",
|
||||
"admin.users.auth_login_name": "Authentication Sign-In Name",
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -502,15 +503,10 @@ func SubmitInstall(ctx *context.Context) {
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
// Auto-login for admin
|
||||
if err = ctx.Session.Set("uid", u.ID); err != nil {
|
||||
if err = ctx.Session.Set(session.KeyUID, u.ID); err != nil {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||
return
|
||||
}
|
||||
if err = ctx.Session.Set("uname", u.Name); err != nil {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||
return
|
||||
}
|
||||
|
||||
if err = ctx.Session.Release(); err != nil {
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||
return
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/routers/web/explore"
|
||||
user_setting "gitea.dev/routers/web/user/setting"
|
||||
auth_service "gitea.dev/services/auth"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/forms"
|
||||
"gitea.dev/services/mailer"
|
||||
@@ -460,6 +461,20 @@ func EditUserPost(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
|
||||
}
|
||||
|
||||
func ImpersonateUser(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
|
||||
if err != nil {
|
||||
ctx.JSONError("unable to get user")
|
||||
return
|
||||
}
|
||||
err = auth_service.ImpersonateUser(ctx.Session, u)
|
||||
if err != nil {
|
||||
ctx.ServerError("unable to impersonate user", err)
|
||||
return
|
||||
}
|
||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
|
||||
}
|
||||
|
||||
// DeleteUser response for deleting a user
|
||||
func DeleteUser(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
|
||||
|
||||
+16
-25
@@ -118,9 +118,8 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||
@@ -357,7 +356,7 @@ func SignInPost(ctx *context.Context) {
|
||||
// User will need to use WebAuthn, save data
|
||||
updates["totpEnrolled"] = u.ID
|
||||
}
|
||||
if err := regenerateSession(ctx, nil, updates); err != nil {
|
||||
if err := regenerateSession(ctx, updates); err != nil {
|
||||
ctx.ServerError("UserSignIn: Unable to update session", err)
|
||||
return
|
||||
}
|
||||
@@ -398,19 +397,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, []string{
|
||||
// Delete the openid, 2fa and link_account data
|
||||
"openid_verified_uri",
|
||||
"openid_signin_remember",
|
||||
"openid_determined_email",
|
||||
"openid_determined_username",
|
||||
"twofaUid",
|
||||
"twofaRemember",
|
||||
"linkAccount",
|
||||
"linkAccountData",
|
||||
}, map[string]any{
|
||||
auth_service.ClearSessionKeysForSignIn(ctx.Session)
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
ctx.ServerError("RegenerateSession", err)
|
||||
@@ -477,6 +466,16 @@ func SignOut(ctx *context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session)
|
||||
if err != nil {
|
||||
ctx.ServerError("ExitImpersonatedUser", err)
|
||||
return
|
||||
}
|
||||
if exitedImpersonated {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
||||
return
|
||||
}
|
||||
|
||||
// prepare the sign-out URL before destroying the session
|
||||
redirectTo := buildSignOutRedirectURL(ctx)
|
||||
HandleSignOut(ctx)
|
||||
@@ -884,10 +883,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
||||
|
||||
log.Trace("User activated: %s", user.Name)
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
"uid": user.ID,
|
||||
"uname": user.Name,
|
||||
}); err != nil {
|
||||
if err := regenerateSession(ctx, map[string]any{session.KeyUID: user.ID}); err != nil {
|
||||
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
|
||||
ctx.ServerError("ActivateUserEmail", err)
|
||||
return
|
||||
@@ -936,17 +932,12 @@ func ActivateEmail(ctx *context.Context) {
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||
}
|
||||
|
||||
func regenerateSession(ctx *context.Context, deletes []string, updates map[string]any) error {
|
||||
func regenerateSession(ctx *context.Context, updates map[string]any) error {
|
||||
if _, err := session.RegenerateSession(ctx.Resp, ctx.Req); err != nil {
|
||||
return fmt.Errorf("regenerate session: %w", err)
|
||||
}
|
||||
sess := ctx.Session
|
||||
sessID := sess.ID()
|
||||
for _, k := range deletes {
|
||||
if err := sess.Delete(k); err != nil {
|
||||
return fmt.Errorf("delete %v in session[%s]: %w", k, sessID, err)
|
||||
}
|
||||
}
|
||||
for k, v := range updates {
|
||||
if err := sess.Set(k, v); err != nil {
|
||||
return fmt.Errorf("set %v in session[%s]: %w", k, sessID, err)
|
||||
|
||||
@@ -169,7 +169,7 @@ func oauth2LinkAccount(ctx *context.Context, u *user_model.User, linkAccountData
|
||||
return
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": remember,
|
||||
|
||||
@@ -428,9 +428,8 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
return
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
session.KeyUID: u.ID,
|
||||
session.KeyUname: u.Name,
|
||||
session.KeyUserHasTwoFactorAuth: userHasTwoFactorAuth,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
@@ -453,7 +452,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
}
|
||||
}
|
||||
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": false,
|
||||
|
||||
@@ -213,7 +213,7 @@ func signInOpenIDVerify(ctx *context.Context) {
|
||||
if u != nil {
|
||||
nickname = u.LowerName
|
||||
}
|
||||
if err := regenerateSession(ctx, nil, map[string]any{
|
||||
if err := regenerateSession(ctx, map[string]any{
|
||||
"openid_verified_uri": id,
|
||||
"openid_determined_email": email,
|
||||
"openid_determined_username": nickname,
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dev/modules/metrics"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/structs"
|
||||
@@ -159,7 +160,7 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
ctx.IsBasicAuth = ar.IsBasicAuth
|
||||
if ctx.Doer == nil {
|
||||
// ensure the session uid is deleted
|
||||
_ = ctx.Session.Delete("uid")
|
||||
_ = ctx.Session.Delete(session.KeyUID)
|
||||
}
|
||||
}
|
||||
return webAuth
|
||||
@@ -795,6 +796,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost)
|
||||
m.Get("/{userid}", admin.ViewUser)
|
||||
m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost)
|
||||
m.Post("/{userid}/impersonate", admin.ImpersonateUser)
|
||||
m.Post("/{userid}/delete", admin.DeleteUser)
|
||||
m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost)
|
||||
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
|
||||
|
||||
+2
-13
@@ -48,19 +48,8 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
|
||||
sess = newSess
|
||||
}
|
||||
|
||||
_ = sess.Delete("openid_verified_uri")
|
||||
_ = sess.Delete("openid_signin_remember")
|
||||
_ = sess.Delete("openid_determined_email")
|
||||
_ = sess.Delete("openid_determined_username")
|
||||
_ = sess.Delete("twofaUid")
|
||||
_ = sess.Delete("twofaRemember")
|
||||
_ = sess.Delete("webauthnAssertion")
|
||||
_ = sess.Delete("linkAccount")
|
||||
err = sess.Set("uid", user.ID)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
err = sess.Set("uname", user.Name)
|
||||
ClearSessionKeysForSignIn(sess)
|
||||
err = sess.Set(session.KeyUID, user.ID)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/session"
|
||||
)
|
||||
|
||||
func ImpersonateUser(sess SessionStore, u *user_model.User) error {
|
||||
if sess.Get(session.KeyImpersonatorData) != nil {
|
||||
return errors.New("already impersonating a user")
|
||||
}
|
||||
// TODO: in the future, we need to process all sessions keys, but the session store doesn't have the ability to list keys
|
||||
// So we need to refactor all "Session.Get" to use consts, then we can enumerate the pre-defined keys.
|
||||
backupKeys := []string{session.KeyUID, session.KeyUserHasTwoFactorAuth}
|
||||
backup := map[string]any{}
|
||||
for _, key := range backupKeys {
|
||||
v := sess.Get(key)
|
||||
if v != nil {
|
||||
backup[key] = v
|
||||
}
|
||||
}
|
||||
err := sess.Set(session.KeyImpersonatorData, backup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set impersonator data: %w", err)
|
||||
}
|
||||
|
||||
ClearSessionKeysForSignIn(sess)
|
||||
data := map[string]any{}
|
||||
data[session.KeyUID] = u.ID
|
||||
data[session.KeyUserHasTwoFactorAuth] = true // since we are impersonating, we don't want to require 2FA for the impersonated user
|
||||
for k, v := range data {
|
||||
if err = sess.Set(k, v); err != nil {
|
||||
return fmt.Errorf("set session data: %w", err)
|
||||
}
|
||||
}
|
||||
return sess.Release()
|
||||
}
|
||||
|
||||
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
|
||||
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
err := sess.Delete(session.KeyImpersonatorData)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete impersonator data: %w", err)
|
||||
}
|
||||
|
||||
ClearSessionKeysForSignIn(sess)
|
||||
for k, v := range impersonatorData {
|
||||
if err = sess.Set(k, v); err != nil {
|
||||
return false, fmt.Errorf("set impersonator data: %w", err)
|
||||
}
|
||||
}
|
||||
return true, sess.Release()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
gouuid "github.com/google/uuid"
|
||||
@@ -117,8 +118,9 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da
|
||||
}
|
||||
}
|
||||
|
||||
if r.CreateSession {
|
||||
if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
|
||||
if r.CreateSession && sess != nil {
|
||||
sessionUID, ok := sess.Get(session.KeyUID).(int64)
|
||||
if !ok || sessionUID != user.ID {
|
||||
handleSignIn(w, req, sess, user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/session"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
@@ -32,20 +33,14 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
uid := sess.Get("uid")
|
||||
if uid == nil {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
log.Trace("Session Authorization: Found user[%d]", uid)
|
||||
|
||||
id, ok := uid.(int64)
|
||||
// Get session user ID
|
||||
uid, ok := sess.Get(session.KeyUID).(int64)
|
||||
if !ok {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
// Get user object
|
||||
user, err := user_model.GetUserByID(req.Context(), id)
|
||||
user, err := user_model.GetUserByID(req.Context(), uid)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("GetUserByID: %v", err)
|
||||
@@ -58,3 +53,15 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto
|
||||
log.Trace("Session Authorization: Logged in user %-v", user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func ClearSessionKeysForSignIn(sess SessionStore) {
|
||||
_ = sess.Delete("openid_verified_uri")
|
||||
_ = sess.Delete("openid_signin_remember")
|
||||
_ = sess.Delete("openid_determined_email")
|
||||
_ = sess.Delete("openid_determined_username")
|
||||
_ = sess.Delete("twofaUid")
|
||||
_ = sess.Delete("twofaRemember")
|
||||
_ = sess.Delete("webauthnAssertion")
|
||||
_ = sess.Delete("linkAccount")
|
||||
_ = sess.Delete("linkAccountData")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
<h4 class="ui top attached header">
|
||||
{{.Title}}
|
||||
<div class="ui right">
|
||||
<a class="ui primary tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.users.edit"}}</a>
|
||||
<button class="ui primary compact tiny basic button link-action" data-url="{{.Link}}/impersonate">{{ctx.Locale.Tr "admin.users.impersonate"}}</button>
|
||||
<a class="ui primary compact tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.users.edit"}}</a>
|
||||
</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
|
||||
@@ -86,10 +86,10 @@
|
||||
</div><!-- end content create new menu -->
|
||||
</div><!-- end dropdown menu create new -->
|
||||
|
||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
<span class="text tw-flex tw-items-center">
|
||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}" data-signed-in-username="{{.SignedUser.Name}}">
|
||||
<span class="flex-text-block">
|
||||
<span class="navbar-avatar">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-2"}}
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24}}
|
||||
{{if .IsAdmin}}{{svg "octicon-shield-check" 16 "navbar-admin-badge"}}{{end}}
|
||||
</span>
|
||||
<span class="only-mobile">{{.SignedUser.Name}}</span>
|
||||
|
||||
@@ -1,105 +1,111 @@
|
||||
<div id="profile-avatar-card" class="ui card">
|
||||
<div id="profile-avatar" class="content tw-flex">
|
||||
{{if eq .SignedUserID .ContextUser.ID}}
|
||||
<a class="image" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
|
||||
{{/* the size doesn't take affect (and no need to take affect), image size(width) should be controlled by the parent container since this is not a flex layout*/}}
|
||||
{{ctx.AvatarUtils.Avatar .ContextUser 256}}
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="image">
|
||||
{{ctx.AvatarUtils.Avatar .ContextUser 256}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="content tw-break-anywhere profile-avatar-name">
|
||||
{{if .ContextUser.FullName}}<span class="header text center">{{.ContextUser.FullName}}</span>{{end}}
|
||||
<span class="username text center">{{.ContextUser.Name}} {{if .IsAdmin}}
|
||||
<a class="muted" href="{{AppSubUrl}}/-/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">
|
||||
{{svg "octicon-gear" 18}}
|
||||
</a>
|
||||
{{end}}</span>
|
||||
<div class="tw-mt-2">
|
||||
<a class="muted" href="{{.ContextUser.HomeLink}}?tab=followers">{{svg "octicon-person" 18 "tw-mr-1"}}{{.NumFollowers}} {{ctx.Locale.Tr "user.followers"}}</a> · <a class="muted" href="{{.ContextUser.HomeLink}}?tab=following">{{.NumFollowing}} {{ctx.Locale.Tr "user.following"}}</a>
|
||||
{{if .EnableFeed}}
|
||||
<a href="{{.ContextUser.HomeLink}}.rss"><i class="ui tw-text-text-light tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">{{svg "octicon-rss" 18}}</i></a>
|
||||
{{end}}
|
||||
<div id="profile-avatar-card" class="ui fitted segment">
|
||||
<div class="flex-divided-list items-px-default muted-links">
|
||||
<div class="item flex-text-block tw-justify-center tw-p-5">
|
||||
{{if eq .SignedUserID .ContextUser.ID}}
|
||||
<a class="flex-text-block" href="{{AppSubUrl}}/user/settings" data-tooltip-content="{{ctx.Locale.Tr "user.change_avatar"}}">
|
||||
{{/* the size doesn't take affect (and no need to take affect), image size(width) should be controlled by the parent container since this is not a flex layout*/}}
|
||||
{{ctx.AvatarUtils.Avatar .ContextUser 256 "profile-avatar-image"}}
|
||||
</a>
|
||||
{{else}}
|
||||
{{ctx.AvatarUtils.Avatar .ContextUser 256 "profile-avatar-image"}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="extra content tw-break-anywhere">
|
||||
<ul>
|
||||
{{if .UserBlocking}}
|
||||
<li class="tw-text-red">{{svg "octicon-circle-slash"}} {{ctx.Locale.Tr "user.block.blocked"}}</li>
|
||||
{{if .UserBlocking.Note}}
|
||||
<li class="tw-text-xs tw-text-red">{{ctx.Locale.Tr "user.block.note"}}: {{.UserBlocking.Note}}</li>
|
||||
|
||||
<div class="item flex-relaxed-list">
|
||||
<span class="tw-text-center">
|
||||
{{.ContextUser.Name}}
|
||||
{{if .IsAdmin}}
|
||||
<a href="{{AppSubUrl}}/-/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">{{svg "octicon-gear" 18}}</a>
|
||||
{{end}}
|
||||
</span>
|
||||
{{if .ContextUser.FullName}}
|
||||
<span class="tw-text-center">{{.ContextUser.FullName}}</span>
|
||||
{{end}}
|
||||
{{if .ContextUser.Location}}
|
||||
<li>
|
||||
{{svg "octicon-location"}}
|
||||
<span class="tw-flex-1">{{.ContextUser.Location}}</span>
|
||||
{{if .ContextUserLocationMapURL}}
|
||||
<a href="{{.ContextUserLocationMapURL}}" data-tooltip-content="{{ctx.Locale.Tr "user.show_on_map"}}">
|
||||
{{svg "octicon-link-external"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</li>
|
||||
<div class="tw-text-center">
|
||||
<a href="{{.ContextUser.HomeLink}}?tab=followers">{{svg "octicon-person" 18 "tw-mr-1"}}{{.NumFollowers}} {{ctx.Locale.Tr "user.followers"}}</a> · <a href="{{.ContextUser.HomeLink}}?tab=following">{{.NumFollowing}} {{ctx.Locale.Tr "user.following"}}</a>
|
||||
{{if .EnableFeed}}
|
||||
<a href="{{.ContextUser.HomeLink}}.rss"><i class="ui tw-text-text-light tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">{{svg "octicon-rss" 18}}</i></a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .UserBlocking}}
|
||||
<div class="item tw-text-red">{{svg "octicon-circle-slash"}} {{ctx.Locale.Tr "user.block.blocked"}}</div>
|
||||
{{if .UserBlocking.Note}}
|
||||
<div class="item tw-text-xs tw-text-red">{{ctx.Locale.Tr "user.block.note"}}: {{.UserBlocking.Note}}</div>
|
||||
{{end}}
|
||||
{{if (eq .SignedUserID .ContextUser.ID)}}
|
||||
<li>
|
||||
{{svg "octicon-mail"}}
|
||||
<a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
|
||||
<a class="flex-text-inline" href="{{AppSubUrl}}/user/settings#privacy-user-settings" data-tooltip-content="{{ctx.Locale.Tr (Iif .ShowUserEmail "user.email_visibility.limited" "user.email_visibility.private")}}">
|
||||
{{svg (Iif .ShowUserEmail "octicon-unlock" "octicon-lock")}}
|
||||
{{end}}
|
||||
|
||||
{{if .ContextUser.Location}}
|
||||
<div class="item">
|
||||
{{svg "octicon-location"}}
|
||||
<span class="tw-flex-1">{{.ContextUser.Location}}</span>
|
||||
{{if .ContextUserLocationMapURL}}
|
||||
<a href="{{.ContextUserLocationMapURL}}" data-tooltip-content="{{ctx.Locale.Tr "user.show_on_map"}}">
|
||||
{{svg "octicon-link-external"}}
|
||||
</a>
|
||||
</li>
|
||||
{{else}}
|
||||
{{if .ShowUserEmail}}
|
||||
<li>
|
||||
{{svg "octicon-mail"}}
|
||||
<a href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if (eq .SignedUserID .ContextUser.ID)}}
|
||||
<div class="item flex-text-inline">
|
||||
{{svg "octicon-mail"}}
|
||||
<a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
|
||||
<a class="flex-text-inline" href="{{AppSubUrl}}/user/settings#privacy-user-settings" data-tooltip-content="{{ctx.Locale.Tr (Iif .ShowUserEmail "user.email_visibility.limited" "user.email_visibility.private")}}">
|
||||
{{svg (Iif .ShowUserEmail "octicon-unlock" "octicon-lock")}}
|
||||
</a>
|
||||
</div>
|
||||
{{else}}
|
||||
{{if .ShowUserEmail}}
|
||||
<div class="item">
|
||||
{{svg "octicon-mail"}}
|
||||
<a href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ContextUser.Website}}
|
||||
<li>
|
||||
{{svg "octicon-link"}}
|
||||
<a target="_blank" rel="me" href="{{.ContextUser.Website}}">{{.ContextUser.Website}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{if .ContextUser.Website}}
|
||||
<div class="item">
|
||||
{{svg "octicon-link"}}
|
||||
<a target="_blank" rel="me" href="{{.ContextUser.Website}}">{{.ContextUser.Website}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if $.RenderedDescription}}
|
||||
<div class="item">
|
||||
<div class="render-content markup">{{$.RenderedDescription}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{range .OpenIDs}}
|
||||
{{if .Show}}
|
||||
<div class="item">
|
||||
{{svg "fontawesome-openid"}}
|
||||
<a target="_blank" href="{{.URI}}">{{.URI}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if $.RenderedDescription}}
|
||||
<li>
|
||||
<div class="render-content markup">{{$.RenderedDescription}}</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{range .OpenIDs}}
|
||||
{{if .Show}}
|
||||
<li>
|
||||
{{svg "fontawesome-openid"}}
|
||||
<a target="_blank" href="{{.URI}}">{{.URI}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<li>{{svg "octicon-calendar"}} <span>{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span></li>
|
||||
{{if and .Orgs .HasOrgsVisible}}
|
||||
<li>
|
||||
<ul class="user-orgs">
|
||||
{{end}}
|
||||
|
||||
<div class="item">{{svg "octicon-calendar"}} <span>{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span></div>
|
||||
|
||||
{{if and .Orgs .HasOrgsVisible}}
|
||||
<div class="item flex-relaxed-list">
|
||||
<div class="flex-text-block tw-flex-wrap">
|
||||
{{range .Orgs}}
|
||||
{{if (or .Visibility.IsPublic (and ($.SignedUser) (or .Visibility.IsLimited (and (.HasMemberWithUserID ctx $.SignedUserID) .Visibility.IsPrivate) ($.IsAdmin))))}}
|
||||
<li>
|
||||
<a href="{{.HomeLink}}" data-tooltip-content="{{.Name}}">
|
||||
{{ctx.AvatarUtils.Avatar .}}
|
||||
</a>
|
||||
</li>
|
||||
<a href="{{.HomeLink}}" data-tooltip-content="{{.Name}}">{{ctx.AvatarUtils.Avatar .}}</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .ShowMoreOrgs}}
|
||||
<li><a class="tw-align-center" href="{{.ContextUser.HomeLink}}?tab=organizations" data-tooltip-content="{{ctx.Locale.Tr "user.show_more"}}">{{svg "octicon-kebab-horizontal" 28 "icon tw-p-1"}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{end}}
|
||||
{{if .Badges}}
|
||||
<li>
|
||||
{{if .ShowMoreOrgs}}
|
||||
<a href="{{.ContextUser.HomeLink}}?tab=organizations" data-tooltip-content="{{ctx.Locale.Tr "user.show_more"}}">{{svg "octicon-kebab-horizontal" 28}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Badges}}
|
||||
<div class="item">
|
||||
<div class="user-badges">
|
||||
{{range .Badges}}
|
||||
<span class="user-badge-item">
|
||||
@@ -111,14 +117,15 @@
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
|
||||
{{if not .UserBlocking}}
|
||||
<li class="follow">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
|
||||
{{if not .UserBlocking}}
|
||||
<div class="item">
|
||||
{{$buttonExtraClass := Iif $.IsFollowing "" "primary"}}
|
||||
{{$followAction := Iif $.IsFollowing "unfollow" "follow"}}
|
||||
<button class="ui basic {{$buttonExtraClass}} button"
|
||||
<button class="ui basic {{$buttonExtraClass}} button tw-w-full"
|
||||
data-fetch-method="post" data-fetch-url="{{.ContextUser.HomeLink}}?action={{$followAction}}"
|
||||
data-fetch-sync="$body #profile-avatar-card"
|
||||
>
|
||||
@@ -128,17 +135,17 @@
|
||||
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.follow"}}
|
||||
{{end}}
|
||||
</button>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
{{if not .UserBlocking}}
|
||||
<a class="muted show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
|
||||
{{else}}
|
||||
<a class="muted" href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
|
||||
{{end}}
|
||||
</li>
|
||||
</div>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<div class="item">
|
||||
{{if not .UserBlocking}}
|
||||
<a class="show-modal" href="#" data-modal="#block-user-modal" data-modal-modal-blockee="{{.ContextUser.Name}}" data-modal-modal-blockee-name="{{.ContextUser.GetDisplayName}}" data-modal-modal-form.url="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</a>
|
||||
{{else}}
|
||||
<a href="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.unblock"}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -102,3 +102,33 @@ func TestAdminDeleteUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminImpersonatedUser(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
session := loginUser(t, "user1")
|
||||
currentUsername := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
|
||||
doc := NewHTMLParser(t, resp.Body)
|
||||
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))
|
||||
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))
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/users/2"), http.StatusForbidden)
|
||||
|
||||
// exit impersonation, current user is user1(admin) again
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther)
|
||||
assert.Equal(t, "user1", currentUsername(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))
|
||||
}
|
||||
|
||||
+2
-65
@@ -1,63 +1,14 @@
|
||||
.user.profile .ui.card .header {
|
||||
display: block;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 1.3rem;
|
||||
margin-top: -0.2rem;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
|
||||
.user.profile .ui.card .profile-avatar-name {
|
||||
border-top: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user.profile .ui.card .extra.content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user.profile .ui.card .extra.content > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user.profile .ui.card .extra.content > ul > li {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.user.profile .ui.card .extra.content > ul > li:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.user.profile .ui.card .extra.content > ul > li.follow .ui.button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user.profile .ui.card #profile-avatar {
|
||||
padding: 1rem 1rem 0.25rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user.profile .ui.card #profile-avatar img {
|
||||
.user.profile img.profile-avatar-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.user.profile .ui.card #profile-avatar img {
|
||||
.user.profile img.profile-avatar-image {
|
||||
width: 30vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.user.profile .ui.card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.user.profile .ui.secondary.stackable.pointing.menu {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -77,20 +28,6 @@
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.user-orgs {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
padding: 0;
|
||||
margin: -3px !important;
|
||||
}
|
||||
|
||||
.user-orgs > li {
|
||||
display: flex;
|
||||
border-bottom: 0 !important;
|
||||
padding: 3px !important;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.user-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user