Add user search
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-25 23:36:53 -05:00
parent 389424ee89
commit 4cfa2c7fff
5 changed files with 133 additions and 20 deletions

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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<UsersTabProps> = ({ rateLimits, allPermissions }) => {
const [currentCursor, setCurrentCursor] = useState('');
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(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<UsersTabProps> = ({ 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<UsersTabProps> = ({ rateLimits, allPermissions }) => {
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Users</Typography>
<Typography variant="body2" color="text.secondary">Page {page}</Typography>
{!activeSearch && <Typography variant="body2" color="text.secondary">Page {page}</Typography>}
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
size="small"
placeholder="Search by username or ID"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: activeSearch ? (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch} edge="end">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : undefined,
},
}}
sx={{ flexGrow: 1 }}
/>
<Button variant="contained" size="small" onClick={handleSearch} disabled={!searchInput.trim()}>
Search
</Button>
</Box>
<Table size="small">
@@ -140,24 +206,26 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
</TableBody>
</Table>
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
<Button
startIcon={<PrevIcon />}
onClick={handlePrev}
disabled={cursorStack.length === 0}
size="small"
>
Previous
</Button>
<Button
endIcon={<NextIcon />}
onClick={handleNext}
disabled={!hasMore}
size="small"
>
Next
</Button>
</Stack>
{!activeSearch && (
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
<Button
startIcon={<PrevIcon />}
onClick={handlePrev}
disabled={cursorStack.length === 0}
size="small"
>
Previous
</Button>
<Button
endIcon={<NextIcon />}
onClick={handleNext}
disabled={!hasMore}
size="small"
>
Next
</Button>
</Stack>
)}
<UserDetailDialog
open={dialogOpen}

View File

@@ -26,6 +26,11 @@ export const adminService = {
credentials: 'include',
}).then(r => r.json()) as Promise<PaginatedUsers>,
searchUsers: (q: string) =>
fetch(`/api/admin/user?q=${encodeURIComponent(q)}`, {
credentials: 'include',
}).then(r => r.json()) as Promise<{ data: AdminUser[] }>,
getUser: (id: number) =>
fetchData<AdminUser>(`/admin/user/${id}`),