This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,6 +206,7 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!activeSearch && (
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
|
||||
<Button
|
||||
startIcon={<PrevIcon />}
|
||||
@@ -158,6 +225,7 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<UserDetailDialog
|
||||
open={dialogOpen}
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user