feat(admin): add user create, disable/enable, and OAuth/authorization management#162
feat(admin): add user create, disable/enable, and OAuth/authorization management#162
Conversation
…authorizations management - Add admin create user page with auto-generated password support - Add disable/enable user with IsActive field, middleware enforcement, and token revocation - Add per-user OAuth connections view with provider badges and unlink action - Add per-user authorized apps view with revoke action - Make user detail stat cards clickable, linking to tokens, connections, and authorizations - Add Status column and Create User button to users list page - Show disabled user count on admin dashboard - Extract getFlashMessage and flashAndRedirect helpers to reduce handler duplication - Sanitize username input on admin user creation using existing sanitizeUsername - Count only active admins in last-admin guard to prevent disabled admins inflating count - Revoke tokens before disabling user to close security window - Add GetOAuthConnectionByUserAndID for efficient ownership-verified queries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds comprehensive admin-side user management capabilities (create local users, enable/disable accounts, and manage per-user OAuth connections/authorizations), plus related UI/metrics updates.
Changes:
- Introduces
User.IsActivewith admin enable/disable flows and session enforcement for disabled users. - Adds new admin pages for creating users, viewing/unlinking OAuth connections, and viewing/revoking authorized apps.
- Enhances admin UI (clickable stat cards, status badges, dashboard disabled-user count) and refactors flash-message helpers.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/templates/static/css/pages/admin-forms.css | Adds link styling for clickable user stat cards. |
| internal/templates/props.go | Adds props structs for new admin user management pages. |
| internal/templates/admin_users.templ | Adds “Create User” CTA and user status column/badge. |
| internal/templates/admin_user_detail.templ | Displays user status, adds clickable stat cards, adds enable/disable actions. |
| internal/templates/admin_user_created.templ | New “created” page showing one-time password with copy action. |
| internal/templates/admin_user_create.templ | New admin “create user” form. |
| internal/templates/admin_user_connections.templ | New OAuth connections list + unlink action UI. |
| internal/templates/admin_user_authorizations.templ | New authorized-apps list + revoke action UI. |
| internal/templates/admin_dashboard.templ | Shows disabled user count when present. |
| internal/store/user.go | Ensures external users are active; changes CountUsersByRole to count only active users. |
| internal/store/user_test.go | Updates test user factory for new IsActive field. |
| internal/store/types/dashboard.go | Adds DisabledUsers to dashboard counts DTO. |
| internal/store/sqlite.go | Seeds default admin with IsActive: true. |
| internal/store/oauth_connection.go | Adds ownership-verified connection lookup by (user_id, id). |
| internal/store/dashboard.go | Adds disabled-users scalar count to dashboard query. |
| internal/services/user.go | Adds admin create user, enable/disable, and OAuth connection deletion service methods. |
| internal/models/user.go | Adds IsActive field to User model. |
| internal/models/audit_log.go | Adds new audit event types for user created/disabled/enabled and OAuth connection deletion. |
| internal/mocks/mock_store.go | Updates mocks for new store interface method. |
| internal/middleware/auth.go | Clears session when a disabled user is encountered. |
| internal/handlers/utils.go | Adds getFlashMessage / flashAndRedirect helpers. |
| internal/handlers/user_admin.go | Adds routes/handlers for create user, enable/disable, connections, authorizations; refactors flash usage. |
| internal/core/store.go | Extends OAuthConnectionStore interface with GetOAuthConnectionByUserAndID. |
| internal/bootstrap/router.go | Registers new admin routes. |
| internal/bootstrap/handlers.go | Wires AuthorizationService into UserAdminHandler. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- URL-encode query params in user detail and authorization templates - Handle non-NotFound DB errors in username/email uniqueness checks - Handle non-NotFound DB errors in OAuth connection lookup - Use operation-neutral error message for self-active-status guard - Block disabled users from authenticating in Authenticate method - Differentiate 400 vs 500 status in CreateUser error handling - Validate disable/enable before revoking tokens to prevent side effects - Add ValidateSetUserActiveStatus for pre-flight guard checks - Add 14 unit tests for CreateUserAdmin, SetUserActiveStatus, DeleteUserOAuthConnection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Replace deprecated strings.Title with manual capitalization - Use sentinel ErrOAuthConnectionNotFound for proper 404 vs 500 handling - Handle gorm.ErrDuplicatedKey race in CreateUserAdmin - Update CountUsersByRole docstring to reflect active-only behavior - Add test for CountUsersByRole excluding disabled users Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // If user exists, check active status then authenticate based on auth_source | ||
| if err == nil { | ||
| if !existingUser.IsActive { | ||
| return nil, ErrAccountDisabled | ||
| } |
There was a problem hiding this comment.
The disabled-account check was added to Authenticate(), but OAuth logins use AuthenticateWithOAuth() (and helpers) which currently don’t verify user.IsActive. A disabled user could still complete an OAuth login flow and potentially obtain new sessions/tokens after being disabled. Consider enforcing IsActive consistently across all authentication paths (e.g., check in AuthenticateWithOAuth after loading the user, and in any external-auth flows that return an existing user) and return ErrAccountDisabled.
|
|
||
| if err := s.store.CreateUser(user); err != nil { | ||
| if errors.Is(err, gorm.ErrDuplicatedKey) { | ||
| return nil, "", ErrUsernameConflict |
There was a problem hiding this comment.
CreateUserAdmin maps any gorm.ErrDuplicatedKey from store.CreateUser to ErrUsernameConflict. Since both username and email have unique indexes, a duplicated key could be caused by an email conflict (race vs. the earlier uniqueness check) and should return ErrEmailConflict (or re-check which field conflicts, similar to Store.UpsertExternalUser).
| return nil, "", ErrUsernameConflict | |
| errText := strings.ToLower(err.Error()) | |
| switch { | |
| case strings.Contains(errText, "email"): | |
| return nil, "", ErrEmailConflict | |
| case strings.Contains(errText, "username"): | |
| return nil, "", ErrUsernameConflict | |
| } |
| ); err != nil { | ||
| renderErrorPage(c, http.StatusBadRequest, err.Error()) | ||
| return |
There was a problem hiding this comment.
toggleUserActive treats all validation errors as HTTP 400. ValidateSetUserActiveStatus can also fail due to internal problems (e.g., DB lookup/count errors), which should be surfaced as 500 (and ideally a generic message) rather than a client error. Consider switching on known guard errors (self/last-admin/already-active/disabled/not-found) and defaulting to 500, similar to DeleteUser().
| currentUser.ID, | ||
| active, | ||
| ); err != nil { | ||
| renderErrorPage(c, http.StatusBadRequest, err.Error()) |
There was a problem hiding this comment.
toggleUserActive renders SetUserActiveStatus errors as HTTP 400 unconditionally. Store/update failures are internal errors and shouldn’t be returned as Bad Request (and err.Error() may leak implementation details). Consider mapping known guard errors to 400/404 and defaulting to 500 with a generic message.
| renderErrorPage(c, http.StatusBadRequest, err.Error()) | |
| renderErrorPage(c, http.StatusInternalServerError, "Failed to update user status") |
| authUUID, | ||
| userID, | ||
| ); err != nil { | ||
| renderErrorPage(c, http.StatusBadRequest, err.Error()) |
There was a problem hiding this comment.
RevokeUserAuthorization returns ErrAuthorizationNotFound when the authorization doesn’t exist, but the handler currently renders any error as HTTP 400. Consider mapping ErrAuthorizationNotFound to 404 (and reserving 400 for true validation errors) to better reflect the failure mode and align with DeleteUserConnection’s 404 handling.
| renderErrorPage(c, http.StatusBadRequest, err.Error()) | |
| status := http.StatusBadRequest | |
| if errors.Is(err, services.ErrAuthorizationNotFound) { | |
| status = http.StatusNotFound | |
| } | |
| renderErrorPage(c, status, err.Error()) |
Summary
/admin/users/newpage with form, auto-generated password, and one-time password displayIsActivefield on User model, middleware enforcement (disabled users auto-logout), token revocation on disable, admin UI toggle/admin/users/:id/connectionspage showing linked providers (GitHub/Gitea/Microsoft) with unlink action/admin/users/:id/authorizationspage showing granted apps with revoke actiongetFlashMessage/flashAndRedirecthelpers, unified disable/enable handler, consolidated error variablesCountUsersByRoleonly counts active admins, username sanitized on create, efficient ownership-verified OAuth connection queriesNew Routes (8)
/admin/users/new/admin/users/admin/users/:id/disable/admin/users/:id/enable/admin/users/:id/connections/admin/users/:id/connections/:conn_id/delete/admin/users/:id/authorizations/admin/users/:id/authorizations/:uuid/revokeModel Change
User.IsActive(bool,default:true) — GORM AutoMigrate handles the migration automatically.Test plan
make generate && make buildsucceedsgo test ./internal/...all packages pass/admin/users/new— verify password shown once🤖 Generated with Claude Code