From 4cfa2c7fff102acc18d3c362b22130e44cc0fd6b Mon Sep 17 00:00:00 2001 From: itzaname Date: Wed, 25 Feb 2026 23:36:53 -0500 Subject: [PATCH] Add user search --- pkg/api/handlers/admin.go | 12 ++- pkg/datastore/datastore.go | 1 + pkg/datastore/gormstore/user.go | 29 ++++++ web/src/components/admin/users/UsersTab.tsx | 106 ++++++++++++++++---- web/src/services/adminService.ts | 5 + 5 files changed, 133 insertions(+), 20 deletions(-) diff --git a/pkg/api/handlers/admin.go b/pkg/api/handlers/admin.go index df75512..1619e61 100644 --- a/pkg/api/handlers/admin.go +++ b/pkg/api/handlers/admin.go @@ -27,8 +27,18 @@ func NewAdminHandler(options ...HandlerOption) (*AdminHandler, error) { // --- User management --- -// ListUsers returns a paginated list of all users +// ListUsers returns a paginated list of all users, or searches by username/ID if ?q= is provided func (h *AdminHandler) ListUsers(ctx *gin.Context) { + if q := ctx.Query("q"); q != "" { + users, err := h.Store.SearchUsers(ctx, q) + if err != nil { + h.RespondWithError(ctx, http.StatusInternalServerError, err.Error()) + return + } + h.RespondWithData(ctx, users, nil) + return + } + pagination := &datastore.CursorPagination{ Cursor: ctx.Query("cursor"), Limit: DefaultPageLimit, diff --git a/pkg/datastore/datastore.go b/pkg/datastore/datastore.go index bfb426d..d383986 100644 --- a/pkg/datastore/datastore.go +++ b/pkg/datastore/datastore.go @@ -33,6 +33,7 @@ type Datastore interface { CreateUser(ctx context.Context, user *model.User) error GetUser(ctx context.Context, id uint64) (*model.User, error) GetAllUsers(ctx context.Context, pagination *CursorPagination) ([]model.User, error) + SearchUsers(ctx context.Context, query string) ([]model.User, error) UpdateUser(ctx context.Context, user *model.User) error DeleteUser(ctx context.Context, id uint64) error AddPermissionToUser(ctx context.Context, userID uint64, permissionID uint32) error diff --git a/pkg/datastore/gormstore/user.go b/pkg/datastore/gormstore/user.go index 7990448..b759037 100644 --- a/pkg/datastore/gormstore/user.go +++ b/pkg/datastore/gormstore/user.go @@ -6,6 +6,7 @@ import ( "git.itzana.me/StrafesNET/dev-service/pkg/datastore" "git.itzana.me/StrafesNET/dev-service/pkg/model" "gorm.io/gorm" + "strconv" "time" ) @@ -76,6 +77,34 @@ func (g *Gormstore) GetAllUsers(ctx context.Context, pagination *datastore.Curso return users, nil } +// SearchUsers searches users by ID (exact) or username (case-insensitive partial match). +func (g *Gormstore) SearchUsers(ctx context.Context, query string) ([]model.User, error) { + q := g.db.WithContext(ctx). + Preload("RateLimit"). + Preload("Permissions", func(db *gorm.DB) *gorm.DB { + return db.Order("permissions.id asc") + }) + + if id, err := strconv.ParseUint(query, 10, 64); err == nil { + q = q.Where("id = ?", id) + } else { + q = q.Where("username ILIKE ?", "%"+query+"%") + } + + var users []model.User + if err := q.Order("id ASC").Find(&users).Error; err != nil { + return nil, err + } + + for i := range users { + if err := g.addDefaultPermissionsToUser(ctx, &users[i]); err != nil { + return nil, err + } + } + + return users, nil +} + // UpdateUser updates an existing User in the database. func (g *Gormstore) UpdateUser(ctx context.Context, user *model.User) error { user.UpdatedAt = time.Now() diff --git a/web/src/components/admin/users/UsersTab.tsx b/web/src/components/admin/users/UsersTab.tsx index a456a7f..c0019a7 100644 --- a/web/src/components/admin/users/UsersTab.tsx +++ b/web/src/components/admin/users/UsersTab.tsx @@ -7,17 +7,21 @@ import { TableRow, TableCell, IconButton, + InputAdornment, Typography, Tooltip, Chip, Button, CircularProgress, Stack, + TextField, } from '@mui/material'; import { NavigateBefore as PrevIcon, NavigateNext as NextIcon, Edit as EditIcon, + Search as SearchIcon, + Clear as ClearIcon, } from '@mui/icons-material'; import { AdminUser, RateLimit, Permission } from '../../../types'; import { adminService } from '../../../services/adminService'; @@ -39,6 +43,8 @@ const UsersTab: React.FC = ({ rateLimits, allPermissions }) => { const [currentCursor, setCurrentCursor] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); + const [searchInput, setSearchInput] = useState(''); + const [activeSearch, setActiveSearch] = useState(''); const { showNotification } = useNotification(); const load = useCallback(async (cursor: string) => { @@ -54,8 +60,38 @@ const UsersTab: React.FC = ({ rateLimits, allPermissions }) => { setLoading(false); }, [showNotification]); + const runSearch = useCallback(async (q: string) => { + setLoading(true); + try { + const res = await adminService.searchUsers(q); + setUsers(res.data ?? []); + setHasMore(false); + setNextCursor(''); + } catch { + showNotification('Failed to search users', 'error'); + } + setLoading(false); + }, [showNotification]); + useEffect(() => { load(''); }, [load]); + const handleSearch = () => { + const q = searchInput.trim(); + if (!q) return; + setActiveSearch(q); + setCursorStack([]); + setCurrentCursor(''); + runSearch(q); + }; + + const handleClearSearch = () => { + setSearchInput(''); + setActiveSearch(''); + setCursorStack([]); + setCurrentCursor(''); + load(''); + }; + const handleNext = () => { setCursorStack(prev => [...prev, currentCursor]); setCurrentCursor(nextCursor); @@ -93,7 +129,37 @@ const UsersTab: React.FC = ({ rateLimits, allPermissions }) => { Users - Page {page} + {!activeSearch && Page {page}} + + + + setSearchInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: activeSearch ? ( + + + + + + ) : undefined, + }, + }} + sx={{ flexGrow: 1 }} + /> + @@ -140,24 +206,26 @@ const UsersTab: React.FC = ({ rateLimits, allPermissions }) => {
- - - - + {!activeSearch && ( + + + + + )} r.json()) as Promise, + searchUsers: (q: string) => + fetch(`/api/admin/user?q=${encodeURIComponent(q)}`, { + credentials: 'include', + }).then(r => r.json()) as Promise<{ data: AdminUser[] }>, + getUser: (id: number) => fetchData(`/admin/user/${id}`),