diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go
index 2d7a77a0..d91d17fb 100644
--- a/internal/bootstrap/handlers.go
+++ b/internal/bootstrap/handlers.go
@@ -96,9 +96,13 @@ func initializeHandlers(deps handlerDeps) handlerSet {
deps.auditService,
deps.cfg,
),
- docs: handlers.NewDocsHandler(deps.templatesFS),
- jwks: jwksHandler,
- userAdmin: handlers.NewUserAdminHandler(deps.services.user, deps.services.token),
+ docs: handlers.NewDocsHandler(deps.templatesFS),
+ jwks: jwksHandler,
+ userAdmin: handlers.NewUserAdminHandler(
+ deps.services.user,
+ deps.services.token,
+ deps.services.authorization,
+ ),
dashboard: handlers.NewDashboardHandler(deps.services.dashboard),
tokenAdmin: handlers.NewTokenAdminHandler(deps.services.token),
userService: deps.services.user,
diff --git a/internal/bootstrap/router.go b/internal/bootstrap/router.go
index 0c0a3064..45ff6d37 100644
--- a/internal/bootstrap/router.go
+++ b/internal/bootstrap/router.go
@@ -314,11 +314,19 @@ func setupAllRoutes(
// User management routes
admin.GET("/users", h.userAdmin.ShowUsersPage)
+ admin.GET("/users/new", h.userAdmin.ShowCreateUserPage)
+ admin.POST("/users", h.userAdmin.CreateUser)
admin.GET("/users/:id", h.userAdmin.ViewUser)
admin.GET("/users/:id/edit", h.userAdmin.ShowEditUserPage)
admin.POST("/users/:id", h.userAdmin.UpdateUser)
admin.POST("/users/:id/reset-password", h.userAdmin.ResetPassword)
admin.POST("/users/:id/delete", h.userAdmin.DeleteUser)
+ admin.POST("/users/:id/disable", h.userAdmin.DisableUser)
+ admin.POST("/users/:id/enable", h.userAdmin.EnableUser)
+ admin.GET("/users/:id/connections", h.userAdmin.ShowUserConnections)
+ admin.POST("/users/:id/connections/:conn_id/delete", h.userAdmin.DeleteUserConnection)
+ admin.GET("/users/:id/authorizations", h.userAdmin.ShowUserAuthorizations)
+ admin.POST("/users/:id/authorizations/:uuid/revoke", h.userAdmin.RevokeUserAuthorization)
// Token management routes
admin.GET("/tokens", h.tokenAdmin.ShowTokensPage)
diff --git a/internal/core/store.go b/internal/core/store.go
index 7279209c..c8b9c2d0 100644
--- a/internal/core/store.go
+++ b/internal/core/store.go
@@ -133,6 +133,7 @@ type OAuthConnectionStore interface {
CreateOAuthConnection(conn *models.OAuthConnection) error
GetOAuthConnection(provider, providerUserID string) (*models.OAuthConnection, error)
GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error)
+ GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error)
GetOAuthConnectionsByUserID(userID string) ([]models.OAuthConnection, error)
UpdateOAuthConnection(conn *models.OAuthConnection) error
DeleteOAuthConnection(id string) error
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
index e0316344..c4608b6d 100644
--- a/internal/handlers/auth.go
+++ b/internal/handlers/auth.go
@@ -132,9 +132,12 @@ func (h *AuthHandler) Login(c *gin.Context,
var errorMsg string
// Check for specific error types
- if errors.Is(err, services.ErrUsernameConflict) {
+ switch {
+ case errors.Is(err, services.ErrAccountDisabled):
+ errorMsg = "Your account has been disabled. Please contact your administrator."
+ case errors.Is(err, services.ErrUsernameConflict):
errorMsg = "Username conflict with existing user. Please contact administrator."
- } else {
+ default:
errorMsg = "Invalid username or password"
}
diff --git a/internal/handlers/oauth_handler.go b/internal/handlers/oauth_handler.go
index 9ff9d8bf..8c24a10e 100644
--- a/internal/handlers/oauth_handler.go
+++ b/internal/handlers/oauth_handler.go
@@ -198,21 +198,26 @@ func (h *OAuthHandler) OAuthCallback(c *gin.Context) {
log.Printf("[OAuth] Authentication failed: %v", err)
// Handle specific errors
- if errors.Is(err, services.ErrOAuthAutoRegisterDisabled) {
+ switch {
+ case errors.Is(err, services.ErrOAuthAutoRegisterDisabled):
renderErrorPage(
c,
http.StatusForbidden,
"Registration Disabled. New account registration via OAuth is currently disabled. Please contact your administrator.",
)
- return
+ case errors.Is(err, services.ErrAccountDisabled):
+ renderErrorPage(
+ c,
+ http.StatusForbidden,
+ "Account Disabled. Your account has been disabled by an administrator. Please contact your administrator for assistance.",
+ )
+ default:
+ renderErrorPage(
+ c,
+ http.StatusInternalServerError,
+ "Authentication failed. Unable to authenticate your account at this time. Please try again later.",
+ )
}
-
- // Generic error
- renderErrorPage(
- c,
- http.StatusInternalServerError,
- "Authentication failed. Unable to authenticate your account at this time. Please try again later.",
- )
return
}
diff --git a/internal/handlers/user_admin.go b/internal/handlers/user_admin.go
index 677f0d7b..c387bfb9 100644
--- a/internal/handlers/user_admin.go
+++ b/internal/handlers/user_admin.go
@@ -3,8 +3,8 @@ package handlers
import (
"errors"
"net/http"
+ "strings"
- "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-authgate/authgate/internal/middleware"
@@ -15,13 +15,22 @@ import (
// UserAdminHandler handles admin user management routes.
type UserAdminHandler struct {
- userService *services.UserService
- tokenService *services.TokenService
+ userService *services.UserService
+ tokenService *services.TokenService
+ authorizationService *services.AuthorizationService
}
// NewUserAdminHandler creates a new UserAdminHandler.
-func NewUserAdminHandler(us *services.UserService, ts *services.TokenService) *UserAdminHandler {
- return &UserAdminHandler{userService: us, tokenService: ts}
+func NewUserAdminHandler(
+ us *services.UserService,
+ ts *services.TokenService,
+ as *services.AuthorizationService,
+) *UserAdminHandler {
+ return &UserAdminHandler{
+ userService: us,
+ tokenService: ts,
+ authorizationService: as,
+ }
}
// adminGetUser fetches a user by ID and renders the appropriate error page on failure.
@@ -58,20 +67,6 @@ func (h *UserAdminHandler) ShowUsersPage(c *gin.Context) {
return
}
- // Retrieve flash messages
- session := sessions.Default(c)
- flashes := session.Flashes()
- if err := session.Save(); err != nil {
- c.Set("session_save_error", err)
- }
-
- var successMsg string
- if len(flashes) > 0 {
- if msg, ok := flashes[0].(string); ok {
- successMsg = msg
- }
- }
-
templates.RenderTempl(c, http.StatusOK, templates.AdminUsers(templates.UsersPageProps{
BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
NavbarProps: buildNavbarProps(c, user, "users"),
@@ -80,7 +75,7 @@ func (h *UserAdminHandler) ShowUsersPage(c *gin.Context) {
Pagination: pagination,
Search: params.Search,
PageSize: params.PageSize,
- Success: successMsg,
+ Success: getFlashMessage(c),
RoleFilter: params.StatusFilter,
AuthSourceFilter: params.CategoryFilter,
}))
@@ -109,20 +104,6 @@ func (h *UserAdminHandler) ViewUser(c *gin.Context) {
return
}
- // Retrieve flash messages
- session := sessions.Default(c)
- flashes := session.Flashes()
- if err := session.Save(); err != nil {
- c.Set("session_save_error", err)
- }
-
- var successMsg string
- if len(flashes) > 0 {
- if msg, ok := flashes[0].(string); ok {
- successMsg = msg
- }
- }
-
templates.RenderTempl(c, http.StatusOK, templates.AdminUserDetail(templates.UserDetailPageProps{
BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
NavbarProps: buildNavbarProps(c, currentUser, "users"),
@@ -130,7 +111,7 @@ func (h *UserAdminHandler) ViewUser(c *gin.Context) {
ActiveTokenCount: stats.ActiveTokenCount,
OAuthConnectionCount: stats.OAuthConnectionCount,
AuthorizationCount: stats.AuthorizationCount,
- Success: successMsg,
+ Success: getFlashMessage(c),
}))
}
@@ -200,13 +181,7 @@ func (h *UserAdminHandler) UpdateUser(c *gin.Context) {
return
}
- session := sessions.Default(c)
- session.AddFlash("User updated successfully.")
- if err := session.Save(); err != nil {
- c.Set("session_save_error", err)
- }
-
- c.Redirect(http.StatusFound, "/admin/users/"+targetUser.ID)
+ flashAndRedirect(c, "User updated successfully.", "/admin/users/"+targetUser.ID)
}
// ResetPassword generates a new random password and displays it once.
@@ -311,12 +286,309 @@ func (h *UserAdminHandler) DeleteUser(c *gin.Context) {
return
}
- session := sessions.Default(c)
- session.AddFlash("User deleted successfully")
- if err := session.Save(); err != nil {
- renderErrorPage(c, http.StatusInternalServerError, "Failed to save session")
+ flashAndRedirect(c, "User deleted successfully.", "/admin/users")
+}
+
+// ── Create User ───────────────────────────────────────────────────────
+
+// ShowCreateUserPage renders the user creation form.
+func (h *UserAdminHandler) ShowCreateUserPage(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ templates.RenderTempl(c, http.StatusOK, templates.AdminUserCreate(templates.UserCreatePageProps{
+ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
+ NavbarProps: buildNavbarProps(c, currentUser, "users"),
+ Role: models.UserRoleUser,
+ }))
+}
+
+// CreateUser handles the user creation form submission.
+func (h *UserAdminHandler) CreateUser(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ req := services.CreateUserRequest{
+ Username: strings.TrimSpace(c.PostForm("username")),
+ Email: strings.TrimSpace(c.PostForm("email")),
+ FullName: strings.TrimSpace(c.PostForm("full_name")),
+ Role: c.PostForm("role"),
+ Password: c.PostForm("password"),
+ }
+
+ user, password, err := h.userService.CreateUserAdmin(
+ c.Request.Context(),
+ req,
+ currentUser.ID,
+ )
+ if err != nil {
+ // Distinguish user-facing validation errors from internal failures
+ status := http.StatusBadRequest
+ errMsg := err.Error()
+ if !errors.Is(err, services.ErrUsernameRequired) &&
+ !errors.Is(err, services.ErrEmailRequired) &&
+ !errors.Is(err, services.ErrInvalidRole) &&
+ !errors.Is(err, services.ErrUsernameConflict) &&
+ !errors.Is(err, services.ErrEmailConflict) {
+ status = http.StatusInternalServerError
+ errMsg = "An internal error occurred. Please try again."
+ }
+ templates.RenderTempl(
+ c,
+ status,
+ templates.AdminUserCreate(templates.UserCreatePageProps{
+ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
+ NavbarProps: buildNavbarProps(c, currentUser, "users"),
+ Error: errMsg,
+ Username: req.Username,
+ Email: req.Email,
+ FullName: req.FullName,
+ Role: req.Role,
+ }),
+ )
+ return
+ }
+
+ c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private")
+ c.Header("Pragma", "no-cache")
+
+ templates.RenderTempl(
+ c,
+ http.StatusOK,
+ templates.AdminUserCreated(templates.UserCreatedPageProps{
+ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
+ NavbarProps: buildNavbarProps(c, currentUser, "users"),
+ TargetUser: user,
+ NewPassword: password,
+ }),
+ )
+}
+
+// ── OAuth Connections ─────────────────────────────────────────────────
+
+// ShowUserConnections renders the user's OAuth connections page.
+func (h *UserAdminHandler) ShowUserConnections(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ targetUser, ok := h.adminGetUser(c)
+ if !ok {
+ return
+ }
+
+ conns, err := h.userService.GetUserOAuthConnections(targetUser.ID)
+ if err != nil {
+ renderErrorPage(c, http.StatusInternalServerError, "Failed to load OAuth connections")
+ return
+ }
+
+ templates.RenderTempl(
+ c,
+ http.StatusOK,
+ templates.AdminUserConnections(templates.UserOAuthConnectionsPageProps{
+ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
+ NavbarProps: buildNavbarProps(c, currentUser, "users"),
+ TargetUser: targetUser,
+ Connections: conns,
+ Success: getFlashMessage(c),
+ }),
+ )
+}
+
+// DeleteUserConnection handles unlinking an OAuth connection.
+func (h *UserAdminHandler) DeleteUserConnection(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ userID := c.Param("id")
+ connID := c.Param("conn_id")
+
+ if err := h.userService.DeleteUserOAuthConnection(
+ c.Request.Context(),
+ userID,
+ connID,
+ currentUser.ID,
+ ); err != nil {
+ if errors.Is(err, services.ErrOAuthConnectionNotFound) {
+ renderErrorPage(c, http.StatusNotFound, err.Error())
+ } else {
+ renderErrorPage(c, http.StatusInternalServerError, "Failed to remove OAuth connection")
+ }
+ return
+ }
+
+ flashAndRedirect(
+ c,
+ "OAuth connection removed successfully.",
+ "/admin/users/"+userID+"/connections",
+ )
+}
+
+// ── User Authorizations ───────────────────────────────────────────────
+
+// ShowUserAuthorizations renders the user's authorized apps page.
+func (h *UserAdminHandler) ShowUserAuthorizations(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ targetUser, ok := h.adminGetUser(c)
+ if !ok {
return
}
- c.Redirect(http.StatusFound, "/admin/users")
+ auths, err := h.authorizationService.ListUserAuthorizations(
+ c.Request.Context(),
+ targetUser.ID,
+ )
+ if err != nil {
+ renderErrorPage(c, http.StatusInternalServerError, "Failed to load authorizations")
+ return
+ }
+
+ // Convert to display models
+ displayAuths := make([]templates.AuthorizationDisplay, 0, len(auths))
+ for _, a := range auths {
+ displayAuths = append(displayAuths, templates.AuthorizationDisplay{
+ UUID: a.UUID,
+ ClientID: a.ClientID,
+ ClientName: a.ClientName,
+ Scopes: a.Scopes,
+ GrantedAt: a.GrantedAt,
+ IsActive: a.IsActive,
+ })
+ }
+
+ templates.RenderTempl(
+ c,
+ http.StatusOK,
+ templates.AdminUserAuthorizations(templates.UserAuthorizationsPageProps{
+ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)},
+ NavbarProps: buildNavbarProps(c, currentUser, "users"),
+ TargetUser: targetUser,
+ Authorizations: displayAuths,
+ Success: getFlashMessage(c),
+ }),
+ )
+}
+
+// RevokeUserAuthorization handles revoking a user's app authorization.
+func (h *UserAdminHandler) RevokeUserAuthorization(c *gin.Context) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ userID := c.Param("id")
+ authUUID := c.Param("uuid")
+
+ if err := h.authorizationService.RevokeUserAuthorization(
+ c.Request.Context(),
+ authUUID,
+ userID,
+ ); err != nil {
+ if errors.Is(err, services.ErrAuthorizationNotFound) {
+ renderErrorPage(c, http.StatusNotFound, err.Error())
+ } else {
+ renderErrorPage(c, http.StatusInternalServerError, "Failed to revoke authorization")
+ }
+ return
+ }
+
+ flashAndRedirect(
+ c,
+ "Authorization revoked successfully.",
+ "/admin/users/"+userID+"/authorizations",
+ )
+}
+
+// ── Disable/Enable User ───────────────────────────────────────────────
+
+// DisableUser handles disabling a user account.
+func (h *UserAdminHandler) DisableUser(c *gin.Context) {
+ h.toggleUserActive(c, false)
+}
+
+// EnableUser handles enabling a user account.
+func (h *UserAdminHandler) EnableUser(c *gin.Context) {
+ h.toggleUserActive(c, true)
+}
+
+// toggleUserActive is the shared implementation for DisableUser and EnableUser.
+func (h *UserAdminHandler) toggleUserActive(c *gin.Context, active bool) {
+ currentUser := getUserFromContext(c)
+ if currentUser == nil {
+ renderErrorPage(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+
+ userID := c.Param("id")
+
+ // Validate first so we don't revoke tokens if the operation will be rejected.
+ if err := h.userService.ValidateSetUserActiveStatus(
+ userID,
+ currentUser.ID,
+ active,
+ ); err != nil {
+ switch {
+ case errors.Is(err, services.ErrCannotDisableSelf),
+ errors.Is(err, services.ErrUserAlreadyActive),
+ errors.Is(err, services.ErrUserAlreadyDisabled),
+ errors.Is(err, services.ErrCannotRemoveLastAdmin):
+ renderErrorPage(c, http.StatusBadRequest, err.Error())
+ case errors.Is(err, services.ErrUserNotFound):
+ renderErrorPage(c, http.StatusNotFound, "User not found")
+ default:
+ renderErrorPage(
+ c,
+ http.StatusInternalServerError,
+ "Failed to validate user status change",
+ )
+ }
+ return
+ }
+
+ // When disabling, revoke all tokens BEFORE changing status to close the
+ // window where a disabled user's tokens could still be valid.
+ if !active {
+ if err := h.tokenService.RevokeAllUserTokens(userID); err != nil {
+ renderErrorPage(
+ c,
+ http.StatusInternalServerError,
+ "Failed to revoke user tokens",
+ )
+ return
+ }
+ }
+
+ if err := h.userService.SetUserActiveStatus(
+ c.Request.Context(),
+ userID,
+ currentUser.ID,
+ active,
+ ); err != nil {
+ renderErrorPage(c, http.StatusInternalServerError, "Failed to update user status")
+ return
+ }
+
+ msg := "User account has been enabled."
+ if !active {
+ msg = "User account has been disabled."
+ }
+ flashAndRedirect(c, msg, "/admin/users/"+userID)
}
diff --git a/internal/handlers/utils.go b/internal/handlers/utils.go
index 76bc4a3a..2378636a 100644
--- a/internal/handlers/utils.go
+++ b/internal/handlers/utils.go
@@ -1,12 +1,14 @@
package handlers
import (
+ "net/http"
"strconv"
"github.com/go-authgate/authgate/internal/models"
"github.com/go-authgate/authgate/internal/store"
"github.com/go-authgate/authgate/internal/templates"
+ "github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -122,6 +124,31 @@ func parseTokenPaginationParams(c *gin.Context) store.PaginationParams {
return params
}
+// getFlashMessage retrieves and clears the first flash message from the session.
+func getFlashMessage(c *gin.Context) string {
+ session := sessions.Default(c)
+ flashes := session.Flashes()
+ if err := session.Save(); err != nil {
+ c.Set("session_save_error", err)
+ }
+ if len(flashes) > 0 {
+ if msg, ok := flashes[0].(string); ok {
+ return msg
+ }
+ }
+ return ""
+}
+
+// flashAndRedirect sets a flash message and redirects to the given URL.
+func flashAndRedirect(c *gin.Context, msg, url string) {
+ session := sessions.Default(c)
+ session.AddFlash(msg)
+ if err := session.Save(); err != nil {
+ c.Set("session_save_error", err)
+ }
+ c.Redirect(http.StatusFound, url)
+}
+
// renderErrorPage renders the error page template with the given status code and message.
func renderErrorPage(c *gin.Context, statusCode int, message string) {
templates.RenderTempl(c, statusCode, templates.ErrorPage(
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
index 5383387f..11004bdf 100644
--- a/internal/middleware/auth.go
+++ b/internal/middleware/auth.go
@@ -69,6 +69,15 @@ func loadUserFromSession(c *gin.Context, userService *services.UserService) (boo
// Transient DB error — don't clear the session
return false, err
}
+ // Check if user account is disabled
+ if !user.IsActive {
+ session.Clear()
+ if saveErr := session.Save(); saveErr != nil {
+ return false, saveErr
+ }
+ return false, nil
+ }
+
c.Set("user_id", userIDStr)
c.Set("user", user)
c.Request = c.Request.WithContext(models.SetUserContext(c.Request.Context(), user))
diff --git a/internal/mocks/mock_store.go b/internal/mocks/mock_store.go
index 2ef3a28f..84364be7 100644
--- a/internal/mocks/mock_store.go
+++ b/internal/mocks/mock_store.go
@@ -1170,6 +1170,21 @@ func (mr *MockOAuthConnectionStoreMockRecorder) GetOAuthConnection(provider, pro
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnection", reflect.TypeOf((*MockOAuthConnectionStore)(nil).GetOAuthConnection), provider, providerUserID)
}
+// GetOAuthConnectionByUserAndID mocks base method.
+func (m *MockOAuthConnectionStore) GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetOAuthConnectionByUserAndID", userID, connectionID)
+ ret0, _ := ret[0].(*models.OAuthConnection)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetOAuthConnectionByUserAndID indicates an expected call of GetOAuthConnectionByUserAndID.
+func (mr *MockOAuthConnectionStoreMockRecorder) GetOAuthConnectionByUserAndID(userID, connectionID any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnectionByUserAndID", reflect.TypeOf((*MockOAuthConnectionStore)(nil).GetOAuthConnectionByUserAndID), userID, connectionID)
+}
+
// GetOAuthConnectionByUserAndProvider mocks base method.
func (m *MockOAuthConnectionStore) GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error) {
m.ctrl.T.Helper()
@@ -2101,6 +2116,21 @@ func (mr *MockStoreMockRecorder) GetOAuthConnection(provider, providerUserID any
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnection", reflect.TypeOf((*MockStore)(nil).GetOAuthConnection), provider, providerUserID)
}
+// GetOAuthConnectionByUserAndID mocks base method.
+func (m *MockStore) GetOAuthConnectionByUserAndID(userID, connectionID string) (*models.OAuthConnection, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetOAuthConnectionByUserAndID", userID, connectionID)
+ ret0, _ := ret[0].(*models.OAuthConnection)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetOAuthConnectionByUserAndID indicates an expected call of GetOAuthConnectionByUserAndID.
+func (mr *MockStoreMockRecorder) GetOAuthConnectionByUserAndID(userID, connectionID any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuthConnectionByUserAndID", reflect.TypeOf((*MockStore)(nil).GetOAuthConnectionByUserAndID), userID, connectionID)
+}
+
// GetOAuthConnectionByUserAndProvider mocks base method.
func (m *MockStore) GetOAuthConnectionByUserAndProvider(userID, provider string) (*models.OAuthConnection, error) {
m.ctrl.T.Helper()
diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go
index 226853e0..551abd84 100644
--- a/internal/models/audit_log.go
+++ b/internal/models/audit_log.go
@@ -37,11 +37,17 @@ const (
EventClientSecretRegenerated EventType = "CLIENT_SECRET_REGENERATED"
// Admin operations — user management
+ EventUserCreated EventType = "USER_CREATED"
EventUserUpdated EventType = "USER_UPDATED"
EventUserDeleted EventType = "USER_DELETED"
+ EventUserDisabled EventType = "USER_DISABLED"
+ EventUserEnabled EventType = "USER_ENABLED"
EventUserRoleChanged EventType = "USER_ROLE_CHANGED"
EventUserPasswordReset EventType = "USER_PASSWORD_RESET" //nolint:gosec // G101: false positive, event type constant
+ // OAuth connection events
+ EventOAuthConnectionDeleted EventType = "OAUTH_CONNECTION_DELETED"
+
// Security events
EventRateLimitExceeded EventType = "RATE_LIMIT_EXCEEDED"
EventSuspiciousActivity EventType = "SUSPICIOUS_ACTIVITY"
diff --git a/internal/models/user.go b/internal/models/user.go
index 61c51bfa..4ab84948 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -24,6 +24,7 @@ type User struct {
Role string `gorm:"not null;default:'user'"` // "admin" or "user"
FullName string // User full name
AvatarURL string // User avatar URL (from OAuth or manual)
+ IsActive bool `gorm:"not null;default:true"` // false = disabled by admin
// External authentication support
ExternalID string `gorm:"index"` // External user ID (e.g., from HTTP API)
diff --git a/internal/services/user.go b/internal/services/user.go
index 5beeebd9..e9f5fda6 100644
--- a/internal/services/user.go
+++ b/internal/services/user.go
@@ -45,6 +45,12 @@ var (
ErrInvalidRole = errors.New("role must be admin or user")
ErrEmailRequired = errors.New("email is required")
ErrEmailConflict = errors.New("email already in use by another user")
+ ErrAccountDisabled = errors.New("account is disabled")
+ ErrUsernameRequired = errors.New("username is required")
+ ErrCannotDisableSelf = errors.New("cannot change your own active status")
+ ErrUserAlreadyActive = errors.New("user is already active")
+ ErrUserAlreadyDisabled = errors.New("user is already disabled")
+ ErrOAuthConnectionNotFound = errors.New("OAuth connection not found")
)
type UserService struct {
@@ -90,8 +96,11 @@ func (s *UserService) Authenticate(
// First, try to find existing user
existingUser, err := s.store.GetUserByUsername(username)
- // If user exists, authenticate based on their auth_source
+ // If user exists, check active status then authenticate based on auth_source
if err == nil {
+ if !existingUser.IsActive {
+ return nil, ErrAccountDisabled
+ }
return s.authenticateExistingUser(ctx, existingUser, password)
}
@@ -338,6 +347,9 @@ func (s *UserService) AuthenticateWithOAuth(
// 2. Check if user exists with same email
user, err := s.store.GetUserByEmail(oauthUserInfo.Email)
if err == nil {
+ if !user.IsActive {
+ return nil, ErrAccountDisabled
+ }
// Only auto-link when the provider has verified the email address.
// Without this check, an attacker who controls an OAuth account with
// a victim's email could take over the victim's AuthGate account.
@@ -391,6 +403,10 @@ func (s *UserService) updateOAuthConnectionAndGetUser(
return nil, fmt.Errorf("user not found for OAuth connection: %w", err)
}
+ if !user.IsActive {
+ return nil, ErrAccountDisabled
+ }
+
// Sync avatar and name if changed
updated := false
if oauthUserInfo.AvatarURL != "" && user.AvatarURL != oauthUserInfo.AvatarURL {
@@ -513,6 +529,7 @@ func (s *UserService) createUserWithOAuth(
AvatarURL: oauthUserInfo.AvatarURL,
Role: models.UserRoleUser,
AuthSource: models.AuthSourceLocal,
+ IsActive: true,
PasswordHash: "", // OAuth users have no password
}
@@ -902,7 +919,245 @@ func (s *UserService) DeleteUserAdmin(
return nil
}
-// CountUsersByRole returns the number of users with the given role.
+// CountUsersByRole returns the number of active users with the given role.
+// Disabled users are excluded so that last-admin guards work correctly.
func (s *UserService) CountUsersByRole(role string) (int64, error) {
return s.store.CountUsersByRole(role)
}
+
+// ── Admin Create User ─────────────────────────────────────────────────
+
+// CreateUserRequest carries the fields for admin user creation.
+type CreateUserRequest struct {
+ Username string
+ Email string
+ FullName string
+ Role string
+ Password string // optional — if empty, generate random
+}
+
+// CreateUserAdmin creates a new local-auth user. Returns the user and
+// the plaintext password (to show once).
+func (s *UserService) CreateUserAdmin(
+ ctx context.Context,
+ req CreateUserRequest,
+ actorUserID string,
+) (*models.User, string, error) {
+ // Validate and sanitize required fields
+ req.Username = sanitizeUsername(strings.TrimSpace(req.Username))
+ req.Email = strings.TrimSpace(req.Email)
+ if req.Username == "" {
+ return nil, "", ErrUsernameRequired
+ }
+ if req.Email == "" {
+ return nil, "", ErrEmailRequired
+ }
+ if req.Role == "" {
+ req.Role = models.UserRoleUser
+ }
+ if req.Role != models.UserRoleAdmin && req.Role != models.UserRoleUser {
+ return nil, "", ErrInvalidRole
+ }
+
+ // Check username uniqueness
+ if _, err := s.store.GetUserByUsername(req.Username); err == nil {
+ return nil, "", ErrUsernameConflict
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, "", fmt.Errorf("failed to check username uniqueness: %w", err)
+ }
+
+ // Check email uniqueness
+ if _, err := s.store.GetUserByEmail(req.Email); err == nil {
+ return nil, "", ErrEmailConflict
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, "", fmt.Errorf("failed to check email uniqueness: %w", err)
+ }
+
+ // Generate password if not provided
+ password := req.Password
+ if password == "" {
+ var err error
+ password, err = util.GenerateRandomPassword(16)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to generate password: %w", err)
+ }
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to hash password: %w", err)
+ }
+
+ user := &models.User{
+ ID: uuid.New().String(),
+ Username: req.Username,
+ Email: req.Email,
+ FullName: req.FullName,
+ Role: req.Role,
+ PasswordHash: string(hash),
+ AuthSource: models.AuthSourceLocal,
+ IsActive: true,
+ }
+
+ if err := s.store.CreateUser(user); err != nil {
+ if errors.Is(err, gorm.ErrDuplicatedKey) {
+ // Re-query to determine which unique constraint was violated (race condition).
+ if _, emailErr := s.store.GetUserByEmail(req.Email); emailErr == nil {
+ return nil, "", ErrEmailConflict
+ }
+ return nil, "", ErrUsernameConflict
+ }
+ return nil, "", fmt.Errorf("failed to create user: %w", err)
+ }
+
+ s.auditService.Log(ctx, core.AuditLogEntry{
+ EventType: models.EventUserCreated,
+ Severity: models.SeverityInfo,
+ ActorUserID: actorUserID,
+ ResourceType: models.ResourceUser,
+ ResourceID: user.ID,
+ ResourceName: user.Username,
+ Action: "User created by admin",
+ Details: models.AuditDetails{
+ "username": user.Username,
+ "email": user.Email,
+ "role": user.Role,
+ },
+ Success: true,
+ })
+
+ return user, password, nil
+}
+
+// ── Admin OAuth Connection Management ─────────────────────────────────
+
+// GetUserOAuthConnections returns all OAuth connections for a user.
+func (s *UserService) GetUserOAuthConnections(userID string) ([]models.OAuthConnection, error) {
+ return s.store.GetOAuthConnectionsByUserID(userID)
+}
+
+// DeleteUserOAuthConnection deletes a specific OAuth connection for a user.
+func (s *UserService) DeleteUserOAuthConnection(
+ ctx context.Context,
+ userID, connectionID, actorUserID string,
+) error {
+ // Verify the connection belongs to this user with a single indexed query
+ target, err := s.store.GetOAuthConnectionByUserAndID(userID, connectionID)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrOAuthConnectionNotFound
+ }
+ return fmt.Errorf("failed to look up OAuth connection: %w", err)
+ }
+
+ if err := s.store.DeleteOAuthConnection(connectionID); err != nil {
+ return fmt.Errorf("failed to delete connection: %w", err)
+ }
+
+ s.auditService.Log(ctx, core.AuditLogEntry{
+ EventType: models.EventOAuthConnectionDeleted,
+ Severity: models.SeverityWarning,
+ ActorUserID: actorUserID,
+ ResourceType: models.ResourceUser,
+ ResourceID: userID,
+ ResourceName: target.Provider + ":" + target.ProviderUsername,
+ Action: "OAuth connection removed by admin",
+ Details: models.AuditDetails{
+ "provider": target.Provider,
+ "provider_username": target.ProviderUsername,
+ "connection_id": connectionID,
+ },
+ Success: true,
+ })
+
+ return nil
+}
+
+// ── Admin Disable/Enable User ─────────────────────────────────────────
+
+// ValidateSetUserActiveStatus checks whether the active status change is
+// allowed without performing it. Callers can use this to run pre-change
+// side effects (e.g. token revocation) before committing the update.
+func (s *UserService) ValidateSetUserActiveStatus(
+ userID, actorUserID string,
+ isActive bool,
+) error {
+ if actorUserID == userID {
+ return ErrCannotDisableSelf
+ }
+
+ user, err := s.AdminGetUserByID(userID)
+ if err != nil {
+ return err
+ }
+
+ if isActive && user.IsActive {
+ return ErrUserAlreadyActive
+ }
+ if !isActive && !user.IsActive {
+ return ErrUserAlreadyDisabled
+ }
+
+ if !isActive && user.Role == models.UserRoleAdmin {
+ adminCount, countErr := s.store.CountUsersByRole(models.UserRoleAdmin)
+ if countErr != nil {
+ return fmt.Errorf("failed to count admins: %w", countErr)
+ }
+ if adminCount <= 1 {
+ return ErrCannotRemoveLastAdmin
+ }
+ }
+
+ return nil
+}
+
+// SetUserActiveStatus enables or disables a user account.
+func (s *UserService) SetUserActiveStatus(
+ ctx context.Context,
+ userID, actorUserID string,
+ isActive bool,
+) error {
+ // Re-validate for defense-in-depth (handler calls ValidateSetUserActiveStatus
+ // separately so it can skip token revocation on validation failure).
+ if err := s.ValidateSetUserActiveStatus(userID, actorUserID, isActive); err != nil {
+ return err
+ }
+
+ user, err := s.AdminGetUserByID(userID)
+ if err != nil {
+ return err
+ }
+
+ user.IsActive = isActive
+ if err := s.store.UpdateUser(user); err != nil {
+ return fmt.Errorf("failed to update user: %w", err)
+ }
+
+ s.InvalidateUserCache(userID)
+
+ eventType := models.EventUserEnabled
+ action := "User account enabled by admin"
+ severity := models.SeverityInfo
+ if !isActive {
+ eventType = models.EventUserDisabled
+ action = "User account disabled by admin"
+ severity = models.SeverityWarning
+ }
+
+ s.auditService.Log(ctx, core.AuditLogEntry{
+ EventType: eventType,
+ Severity: severity,
+ ActorUserID: actorUserID,
+ ResourceType: models.ResourceUser,
+ ResourceID: userID,
+ ResourceName: user.Username,
+ Action: action,
+ Details: models.AuditDetails{
+ "username": user.Username,
+ "is_active": isActive,
+ },
+ Success: true,
+ })
+
+ return nil
+}
diff --git a/internal/services/user_test.go b/internal/services/user_test.go
index e635aabb..dabb9b1a 100644
--- a/internal/services/user_test.go
+++ b/internal/services/user_test.go
@@ -7,7 +7,9 @@ import (
"time"
"go.uber.org/mock/gomock"
+ "golang.org/x/oauth2"
+ "github.com/go-authgate/authgate/internal/auth"
"github.com/go-authgate/authgate/internal/core"
"github.com/go-authgate/authgate/internal/mocks"
"github.com/go-authgate/authgate/internal/models"
@@ -40,6 +42,7 @@ func makeTestUser(t *testing.T, db *store.Store) *models.User {
PasswordHash: "hash",
Role: "user",
AuthSource: AuthModeLocal,
+ IsActive: true,
}
require.NoError(t, db.CreateUser(u))
return u
@@ -605,3 +608,303 @@ func TestGetUserStats(t *testing.T) {
assert.Equal(t, int64(0), stats.OAuthConnectionCount)
assert.Equal(t, int64(0), stats.AuthorizationCount)
}
+
+// ── CreateUserAdmin tests ─────────────────────────────────────────────
+
+func TestCreateUserAdmin_Success(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ user, password, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Username: "newuser",
+ Email: "new@example.com",
+ FullName: "New User",
+ }, "actor-id")
+ require.NoError(t, err)
+ assert.Equal(t, "newuser", user.Username)
+ assert.Equal(t, "new@example.com", user.Email)
+ assert.Equal(t, models.UserRoleUser, user.Role)
+ assert.True(t, user.IsActive)
+ assert.NotEmpty(t, password)
+}
+
+func TestCreateUserAdmin_UsernameRequired(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Email: "test@example.com",
+ }, "actor-id")
+ assert.ErrorIs(t, err, ErrUsernameRequired)
+}
+
+func TestCreateUserAdmin_DuplicateUsername(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Username: u.Username,
+ Email: "unique@example.com",
+ }, "actor-id")
+ assert.ErrorIs(t, err, ErrUsernameConflict)
+}
+
+func TestCreateUserAdmin_DuplicateEmail(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Username: "uniqueuser",
+ Email: u.Email,
+ }, "actor-id")
+ assert.ErrorIs(t, err, ErrEmailConflict)
+}
+
+func TestCreateUserAdmin_InvalidRole(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Username: "newuser",
+ Email: "new@example.com",
+ Role: "superadmin",
+ }, "actor-id")
+ assert.ErrorIs(t, err, ErrInvalidRole)
+}
+
+func TestCreateUserAdmin_SanitizesUsername(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ user, _, err := svc.CreateUserAdmin(context.Background(), CreateUserRequest{
+ Username: "John Doe!@#",
+ Email: "john@example.com",
+ }, "actor-id")
+ require.NoError(t, err)
+ assert.Equal(t, "johndoe", user.Username)
+}
+
+// ── SetUserActiveStatus tests ─────────────────────────────────────────
+
+func TestSetUserActiveStatus_DisableUser(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
+
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", false)
+ require.NoError(t, err)
+
+ updated, err := svc.AdminGetUserByID(u.ID)
+ require.NoError(t, err)
+ assert.False(t, updated.IsActive)
+}
+
+func TestSetUserActiveStatus_EnableUser(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
+
+ u := makeTestUser(t, db)
+ // First disable
+ u.IsActive = false
+ require.NoError(t, db.UpdateUser(u))
+
+ svc := newUserServiceWithStore(db, mockCache)
+ err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", true)
+ require.NoError(t, err)
+
+ updated, err := svc.AdminGetUserByID(u.ID)
+ require.NoError(t, err)
+ assert.True(t, updated.IsActive)
+}
+
+func TestSetUserActiveStatus_CannotDisableSelf(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ err := svc.SetUserActiveStatus(context.Background(), u.ID, u.ID, false)
+ assert.ErrorIs(t, err, ErrCannotDisableSelf)
+}
+
+func TestSetUserActiveStatus_AlreadyDisabled(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+ mockCache.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
+
+ u := makeTestUser(t, db)
+ u.IsActive = false
+ require.NoError(t, db.UpdateUser(u))
+
+ svc := newUserServiceWithStore(db, mockCache)
+ err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", false)
+ assert.ErrorIs(t, err, ErrUserAlreadyDisabled)
+}
+
+func TestSetUserActiveStatus_AlreadyActive(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ err := svc.SetUserActiveStatus(context.Background(), u.ID, "other-actor", true)
+ assert.ErrorIs(t, err, ErrUserAlreadyActive)
+}
+
+// ── DeleteUserOAuthConnection tests ───────────────────────────────────
+
+func TestDeleteUserOAuthConnection_Success(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ u := makeTestUser(t, db)
+ conn := &models.OAuthConnection{
+ ID: uuid.New().String(),
+ UserID: u.ID,
+ Provider: "github",
+ ProviderUserID: "gh-123",
+ ProviderUsername: "ghuser",
+ ProviderEmail: "gh@example.com",
+ }
+ require.NoError(t, db.CreateOAuthConnection(conn))
+
+ svc := newUserServiceWithStore(db, mockCache)
+ err := svc.DeleteUserOAuthConnection(context.Background(), u.ID, conn.ID, "actor-id")
+ require.NoError(t, err)
+
+ // Verify it's deleted
+ conns, err := db.GetOAuthConnectionsByUserID(u.ID)
+ require.NoError(t, err)
+ assert.Empty(t, conns)
+}
+
+func TestDeleteUserOAuthConnection_NotFound(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ u := makeTestUser(t, db)
+ svc := newUserServiceWithStore(db, mockCache)
+
+ err := svc.DeleteUserOAuthConnection(context.Background(), u.ID, "nonexistent", "actor-id")
+ require.ErrorIs(t, err, ErrOAuthConnectionNotFound)
+}
+
+func TestDeleteUserOAuthConnection_WrongUser(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ u1 := makeTestUser(t, db)
+ u2 := makeTestUser(t, db)
+ conn := &models.OAuthConnection{
+ ID: uuid.New().String(),
+ UserID: u1.ID,
+ Provider: "github",
+ ProviderUserID: "gh-456",
+ ProviderUsername: "ghuser2",
+ ProviderEmail: "gh2@example.com",
+ }
+ require.NoError(t, db.CreateOAuthConnection(conn))
+
+ svc := newUserServiceWithStore(db, mockCache)
+ // Attempt to delete u1's connection using u2's ID
+ err := svc.DeleteUserOAuthConnection(context.Background(), u2.ID, conn.ID, "actor-id")
+ require.ErrorIs(t, err, ErrOAuthConnectionNotFound)
+
+ // Verify the connection still exists
+ conns, err := db.GetOAuthConnectionsByUserID(u1.ID)
+ require.NoError(t, err)
+ assert.Len(t, conns, 1)
+}
+
+// ── AuthenticateWithOAuth disabled user tests ──────────────────────────
+
+func TestAuthenticateWithOAuth_DisabledUserWithExistingConnection(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ // Create a disabled user
+ u := makeTestUser(t, db)
+ u.IsActive = false
+ require.NoError(t, db.UpdateUser(u))
+
+ // Create an OAuth connection for the disabled user
+ conn := &models.OAuthConnection{
+ ID: uuid.New().String(),
+ UserID: u.ID,
+ Provider: "github",
+ ProviderUserID: "gh-disabled-" + uuid.New().String()[:8],
+ ProviderUsername: "disableduser",
+ ProviderEmail: u.Email,
+ }
+ require.NoError(t, db.CreateOAuthConnection(conn))
+
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, err := svc.AuthenticateWithOAuth(
+ context.Background(),
+ "github",
+ &auth.OAuthUserInfo{
+ ProviderUserID: conn.ProviderUserID,
+ Email: u.Email,
+ Username: "disableduser",
+ EmailVerified: true,
+ },
+ &oauth2.Token{AccessToken: "tok"},
+ )
+ require.ErrorIs(t, err, ErrAccountDisabled)
+}
+
+func TestAuthenticateWithOAuth_DisabledUserByEmail(t *testing.T) {
+ db := setupTestStore(t)
+ ctrl := gomock.NewController(t)
+ mockCache := mocks.NewMockCache[models.User](ctrl)
+
+ // Create a disabled user (no OAuth connection)
+ u := makeTestUser(t, db)
+ u.IsActive = false
+ require.NoError(t, db.UpdateUser(u))
+
+ svc := newUserServiceWithStore(db, mockCache)
+
+ _, err := svc.AuthenticateWithOAuth(
+ context.Background(),
+ "github",
+ &auth.OAuthUserInfo{
+ ProviderUserID: "gh-new-" + uuid.New().String()[:8],
+ Email: u.Email,
+ Username: "newuser",
+ EmailVerified: true,
+ },
+ &oauth2.Token{AccessToken: "tok"},
+ )
+ require.ErrorIs(t, err, ErrAccountDisabled)
+}
diff --git a/internal/store/dashboard.go b/internal/store/dashboard.go
index 03165f4d..db967e2a 100644
--- a/internal/store/dashboard.go
+++ b/internal/store/dashboard.go
@@ -15,6 +15,7 @@ func (s *Store) GetDashboardCounts() (DashboardCounts, error) {
SELECT
(SELECT COUNT(*) FROM users) AS total_users,
(SELECT COUNT(*) FROM users WHERE role = ?) AS admin_users,
+ (SELECT COUNT(*) FROM users WHERE is_active = ?) AS disabled_users,
(SELECT COUNT(*) FROM oauth_applications) AS total_clients,
(SELECT COUNT(*) FROM oauth_applications WHERE status = ?) AS active_clients,
(SELECT COUNT(*) FROM oauth_applications WHERE status = ?) AS pending_clients,
@@ -22,6 +23,7 @@ func (s *Store) GetDashboardCounts() (DashboardCounts, error) {
(SELECT COUNT(*) FROM access_tokens WHERE status = ? AND expires_at > ? AND token_category = ?) AS active_refresh_tokens
`,
models.UserRoleAdmin,
+ false,
models.ClientStatusActive,
models.ClientStatusPending,
models.TokenStatusActive, now, models.TokenCategoryAccess,
diff --git a/internal/store/oauth_connection.go b/internal/store/oauth_connection.go
index d25dd0d1..75a74dd0 100644
--- a/internal/store/oauth_connection.go
+++ b/internal/store/oauth_connection.go
@@ -44,6 +44,19 @@ func (s *Store) GetOAuthConnectionsByUserID(userID string) ([]models.OAuthConnec
return conns, err
}
+// GetOAuthConnectionByUserAndID finds an OAuth connection by user ID and connection ID.
+func (s *Store) GetOAuthConnectionByUserAndID(
+ userID, connectionID string,
+) (*models.OAuthConnection, error) {
+ var conn models.OAuthConnection
+ err := s.db.Where("user_id = ? AND id = ?", userID, connectionID).
+ First(&conn).Error
+ if err != nil {
+ return nil, err
+ }
+ return &conn, nil
+}
+
// UpdateOAuthConnection updates an existing OAuth connection
func (s *Store) UpdateOAuthConnection(conn *models.OAuthConnection) error {
return s.db.Save(conn).Error
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
index 26bba23a..be2a897c 100644
--- a/internal/store/sqlite.go
+++ b/internal/store/sqlite.go
@@ -179,6 +179,7 @@ func (s *Store) seedData(ctx context.Context, cfg *config.Config) error {
Email: "admin@localhost", // Default email for admin
PasswordHash: string(hash),
Role: models.UserRoleAdmin,
+ IsActive: true,
}
if err := s.db.WithContext(ctx).Create(user).Error; err != nil {
return err
diff --git a/internal/store/types/dashboard.go b/internal/store/types/dashboard.go
index 74c57ad6..21cf2a9d 100644
--- a/internal/store/types/dashboard.go
+++ b/internal/store/types/dashboard.go
@@ -5,6 +5,7 @@ package types
type DashboardCounts struct {
TotalUsers int64 `gorm:"column:total_users"`
AdminUsers int64 `gorm:"column:admin_users"`
+ DisabledUsers int64 `gorm:"column:disabled_users"`
TotalClients int64 `gorm:"column:total_clients"`
ActiveClients int64 `gorm:"column:active_clients"`
PendingClients int64 `gorm:"column:pending_clients"`
diff --git a/internal/store/user.go b/internal/store/user.go
index 148070dc..0ff80162 100644
--- a/internal/store/user.go
+++ b/internal/store/user.go
@@ -105,6 +105,7 @@ func (s *Store) UpsertExternalUser(
Username: username,
PasswordHash: "", // No local password for external users
Role: models.UserRoleUser,
+ IsActive: true,
ExternalID: externalID,
AuthSource: authSource,
Email: email,
@@ -217,10 +218,12 @@ func (s *Store) ListUsersPaginated(
return users, pagination, nil
}
-// CountUsersByRole returns the number of users with the given role.
+// CountUsersByRole returns the number of active users with the given role.
+// Only active users are counted so that disabled admins do not inflate the
+// "last admin" guard used by disable/delete operations.
func (s *Store) CountUsersByRole(role string) (int64, error) {
var count int64
- query := s.db.Model(&models.User{})
+ query := s.db.Model(&models.User{}).Where("is_active = ?", true)
if role != "" {
query = query.Where("role = ?", role)
}
diff --git a/internal/store/user_test.go b/internal/store/user_test.go
index dd52b887..48043372 100644
--- a/internal/store/user_test.go
+++ b/internal/store/user_test.go
@@ -20,6 +20,7 @@ func createTestUser(t *testing.T, s *Store, overrides *models.User) *models.User
PasswordHash: "hashed",
Role: models.UserRoleUser,
AuthSource: models.AuthSourceLocal,
+ IsActive: true,
}
if overrides != nil {
if overrides.Username != "" {
@@ -177,6 +178,22 @@ func TestCountUsersByRole(t *testing.T) {
require.NoError(t, err)
assert.GreaterOrEqual(t, userCount, int64(1))
})
+
+ t.Run("excludes disabled users", func(t *testing.T) {
+ store := createFreshStore(t, "sqlite", nil)
+ // Seeded admin is active. Create a disabled admin.
+ disabledAdmin := createTestUser(t, store, &models.User{
+ Role: models.UserRoleAdmin,
+ Email: uuid.New().String()[:8] + "@test.com",
+ })
+ disabledAdmin.IsActive = false
+ require.NoError(t, store.UpdateUser(disabledAdmin))
+
+ adminCount, err := store.CountUsersByRole(models.UserRoleAdmin)
+ require.NoError(t, err)
+ // Only the seeded admin should be counted, not the disabled one
+ assert.Equal(t, int64(1), adminCount)
+ })
}
func TestGetUserStatsByUserID(t *testing.T) {
diff --git a/internal/templates/admin_dashboard.templ b/internal/templates/admin_dashboard.templ
index c86c9666..7fbc9ff9 100644
--- a/internal/templates/admin_dashboard.templ
+++ b/internal/templates/admin_dashboard.templ
@@ -38,6 +38,11 @@ templ AdminDashboard(props DashboardPageProps) {
Total Users
{ pluralize(props.Stats.AdminUsers, "admin", "admins") }, { pluralize(props.Stats.RegularUsers, "user", "users") }
+ if props.Stats.DisabledUsers > 0 {
+
+ ({ fmt.Sprintf("%d", props.Stats.DisabledUsers) } disabled)
+
+ }
diff --git a/internal/templates/admin_user_authorizations.templ b/internal/templates/admin_user_authorizations.templ
new file mode 100644
index 00000000..984cb200
--- /dev/null
+++ b/internal/templates/admin_user_authorizations.templ
@@ -0,0 +1,103 @@
+package templates
+
+import (
+ "fmt"
+ "net/url"
+)
+
+templ AdminUserAuthorizations(props UserAuthorizationsPageProps) {
+ @Layout("User Authorizations", LayoutAdminNavbar, &props.NavbarProps) {
+
+ }
+}
diff --git a/internal/templates/admin_user_connections.templ b/internal/templates/admin_user_connections.templ
new file mode 100644
index 00000000..b03a90e3
--- /dev/null
+++ b/internal/templates/admin_user_connections.templ
@@ -0,0 +1,149 @@
+package templates
+
+import (
+ "fmt"
+ "strings"
+)
+
+func providerDisplayName(provider string) string {
+ switch strings.ToLower(provider) {
+ case "github":
+ return "GitHub"
+ case "gitea":
+ return "Gitea"
+ case "microsoft":
+ return "Microsoft"
+ default:
+ if provider == "" {
+ return ""
+ }
+ return strings.ToUpper(provider[:1]) + provider[1:]
+ }
+}
+
+templ AdminUserConnections(props UserOAuthConnectionsPageProps) {
+ @Layout("OAuth Connections", LayoutAdminNavbar, &props.NavbarProps) {
+
+ }
+}
+
+templ oauthProviderBadge(provider string) {
+ switch strings.ToLower(provider) {
+ case "github":
+
+ GitHub
+
+ case "gitea":
+
+ Gitea
+
+ case "microsoft":
+
+ Microsoft
+
+ default:
+
+ { providerDisplayName(provider) }
+
+ }
+}
diff --git a/internal/templates/admin_user_create.templ b/internal/templates/admin_user_create.templ
new file mode 100644
index 00000000..0e1e0b13
--- /dev/null
+++ b/internal/templates/admin_user_create.templ
@@ -0,0 +1,98 @@
+package templates
+
+import "github.com/go-authgate/authgate/internal/models"
+
+templ AdminUserCreate(props UserCreatePageProps) {
+ @Layout("Create User", LayoutAdminNavbar, &props.NavbarProps) {
+
+ }
+}
diff --git a/internal/templates/admin_user_created.templ b/internal/templates/admin_user_created.templ
new file mode 100644
index 00000000..e1b46c02
--- /dev/null
+++ b/internal/templates/admin_user_created.templ
@@ -0,0 +1,82 @@
+package templates
+
+templ AdminUserCreated(props UserCreatedPageProps) {
+ @Layout("User Created", LayoutAdminNavbar, &props.NavbarProps) {
+
+
+
+ @Breadcrumb([]BreadcrumbItem{
+ {Label: "Admin", Href: "/admin"},
+ {Label: "Users", Href: "/admin/users"},
+ {Label: props.TargetUser.Username, Href: "/admin/users/" + props.TargetUser.ID},
+ {Label: "Created", Href: ""},
+ })
+
+
+ User "{ props.TargetUser.Username }" has been created successfully.
+
+
+
⚠
+
+
Important Security Notice
+
+ This password is shown only once for security reasons.
+ Please copy and share it with the user through a secure channel immediately.
+
+
+
+
+
+
{ props.NewPassword }
+
+
+
+
+
+
Username
+
{ props.TargetUser.Username }
+
+
+
Email
+
{ props.TargetUser.Email }
+
+ if props.TargetUser.FullName != "" {
+
+
Full Name
+
{ props.TargetUser.FullName }
+
+ }
+
+
Role
+
+ @UserRoleBadge(props.TargetUser.Role)
+
+
+
+
+
+
+
+ }
+}
diff --git a/internal/templates/admin_user_detail.templ b/internal/templates/admin_user_detail.templ
index 78937af8..1d0c0332 100644
--- a/internal/templates/admin_user_detail.templ
+++ b/internal/templates/admin_user_detail.templ
@@ -2,6 +2,7 @@ package templates
import (
"fmt"
+ "net/url"
"github.com/go-authgate/authgate/internal/models"
)
@@ -50,6 +51,12 @@ templ AdminUserDetail(props UserDetailPageProps) {
@UserRoleBadge(props.TargetUser.Role)
+
+
Status
+
+ @UserStatusBadge(props.TargetUser.IsActive)
+
+
Auth Source
@@ -65,20 +72,20 @@ templ AdminUserDetail(props UserDetailPageProps) {
{ props.TargetUser.UpdatedAt.Format("2006-01-02 15:04:05") }
-
+
}
}
+
+templ UserStatusBadge(isActive bool) {
+ if isActive {
+ Active
+ } else {
+ Disabled
+ }
+}
diff --git a/internal/templates/admin_users.templ b/internal/templates/admin_users.templ
index 1ffb3da4..c938ae74 100644
--- a/internal/templates/admin_users.templ
+++ b/internal/templates/admin_users.templ
@@ -40,6 +40,10 @@ templ AdminUsers(props UsersPageProps) {
Users
+
+
+ Create User
+
@Alert(props.Success, AlertSuccess)
@SfToolbar() {
@@ -104,6 +108,11 @@ templ AdminUsers(props UsersPageProps) {
Auth Source
+
+
+ |
|