Skip to content

feat(admin): add user create, disable/enable, and OAuth/authorization management#162

Open
appleboy wants to merge 3 commits intomainfrom
worktree-admin
Open

feat(admin): add user create, disable/enable, and OAuth/authorization management#162
appleboy wants to merge 3 commits intomainfrom
worktree-admin

Conversation

@appleboy
Copy link
Copy Markdown
Member

Summary

  • Admin Create User — new /admin/users/new page with form, auto-generated password, and one-time password display
  • Disable/Enable UserIsActive field on User model, middleware enforcement (disabled users auto-logout), token revocation on disable, admin UI toggle
  • OAuth Connections View — per-user /admin/users/:id/connections page showing linked providers (GitHub/Gitea/Microsoft) with unlink action
  • User Authorizations View — per-user /admin/users/:id/authorizations page showing granted apps with revoke action
  • Clickable Stat Cards — user detail stat cards (Active Tokens, OAuth Connections, Authorized Apps) now link to their respective management pages
  • Dashboard Update — shows disabled user count when present
  • Code Quality — extracted getFlashMessage/flashAndRedirect helpers, unified disable/enable handler, consolidated error variables
  • Security Hardening — tokens revoked before disable (not after), CountUsersByRole only counts active admins, username sanitized on create, efficient ownership-verified OAuth connection queries

New Routes (8)

Method Path Description
GET /admin/users/new Create user form
POST /admin/users Submit create user
POST /admin/users/:id/disable Disable user
POST /admin/users/:id/enable Enable user
GET /admin/users/:id/connections View OAuth connections
POST /admin/users/:id/connections/:conn_id/delete Unlink OAuth provider
GET /admin/users/:id/authorizations View authorized apps
POST /admin/users/:id/authorizations/:uuid/revoke Revoke authorization

Model Change

User.IsActive (bool, default:true) — GORM AutoMigrate handles the migration automatically.

Test plan

  • make generate && make build succeeds
  • go test ./internal/... all packages pass
  • Create user via /admin/users/new — verify password shown once
  • Disable user → verify they are logged out and cannot log in
  • Enable user → verify they can log in again
  • Cannot disable self or last admin
  • View/unlink OAuth connections from user detail page
  • View/revoke authorized apps from user detail page
  • Stat cards on user detail page link to correct pages
  • Dashboard shows disabled user count

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings April 10, 2026 14:54
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.IsActive with 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@appleboy appleboy requested a review from Copilot April 10, 2026 15:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +99 to +103
// If user exists, check active status then authenticate based on auth_source
if err == nil {
if !existingUser.IsActive {
return nil, ErrAccountDisabled
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

if err := s.store.CreateUser(user); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, "", ErrUsernameConflict
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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
}

Copilot uses AI. Check for mistakes.
Comment on lines +542 to +544
); err != nil {
renderErrorPage(c, http.StatusBadRequest, err.Error())
return
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
currentUser.ID,
active,
); err != nil {
renderErrorPage(c, http.StatusBadRequest, err.Error())
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
renderErrorPage(c, http.StatusBadRequest, err.Error())
renderErrorPage(c, http.StatusInternalServerError, "Failed to update user status")

Copilot uses AI. Check for mistakes.
authUUID,
userID,
); err != nil {
renderErrorPage(c, http.StatusBadRequest, err.Error())
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
renderErrorPage(c, http.StatusBadRequest, err.Error())
status := http.StatusBadRequest
if errors.Is(err, services.ErrAuthorizationNotFound) {
status = http.StatusNotFound
}
renderErrorPage(c, status, err.Error())

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants