This commit is contained in:
@@ -27,8 +27,18 @@ func NewAdminHandler(options ...HandlerOption) (*AdminHandler, error) {
|
|||||||
|
|
||||||
// --- User management ---
|
// --- 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) {
|
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{
|
pagination := &datastore.CursorPagination{
|
||||||
Cursor: ctx.Query("cursor"),
|
Cursor: ctx.Query("cursor"),
|
||||||
Limit: DefaultPageLimit,
|
Limit: DefaultPageLimit,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Datastore interface {
|
|||||||
CreateUser(ctx context.Context, user *model.User) error
|
CreateUser(ctx context.Context, user *model.User) error
|
||||||
GetUser(ctx context.Context, id uint64) (*model.User, error)
|
GetUser(ctx context.Context, id uint64) (*model.User, error)
|
||||||
GetAllUsers(ctx context.Context, pagination *CursorPagination) ([]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
|
UpdateUser(ctx context.Context, user *model.User) error
|
||||||
DeleteUser(ctx context.Context, id uint64) error
|
DeleteUser(ctx context.Context, id uint64) error
|
||||||
AddPermissionToUser(ctx context.Context, userID uint64, permissionID uint32) 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/datastore"
|
||||||
"git.itzana.me/StrafesNET/dev-service/pkg/model"
|
"git.itzana.me/StrafesNET/dev-service/pkg/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,6 +77,34 @@ func (g *Gormstore) GetAllUsers(ctx context.Context, pagination *datastore.Curso
|
|||||||
return users, nil
|
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.
|
// UpdateUser updates an existing User in the database.
|
||||||
func (g *Gormstore) UpdateUser(ctx context.Context, user *model.User) error {
|
func (g *Gormstore) UpdateUser(ctx context.Context, user *model.User) error {
|
||||||
user.UpdatedAt = time.Now()
|
user.UpdatedAt = time.Now()
|
||||||
|
|||||||
@@ -7,17 +7,21 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
Typography,
|
Typography,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Chip,
|
Chip,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Stack,
|
Stack,
|
||||||
|
TextField,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
NavigateBefore as PrevIcon,
|
NavigateBefore as PrevIcon,
|
||||||
NavigateNext as NextIcon,
|
NavigateNext as NextIcon,
|
||||||
Edit as EditIcon,
|
Edit as EditIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
Clear as ClearIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { AdminUser, RateLimit, Permission } from '../../../types';
|
import { AdminUser, RateLimit, Permission } from '../../../types';
|
||||||
import { adminService } from '../../../services/adminService';
|
import { adminService } from '../../../services/adminService';
|
||||||
@@ -39,6 +43,8 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
|||||||
const [currentCursor, setCurrentCursor] = useState('');
|
const [currentCursor, setCurrentCursor] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [activeSearch, setActiveSearch] = useState('');
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
const load = useCallback(async (cursor: string) => {
|
const load = useCallback(async (cursor: string) => {
|
||||||
@@ -54,8 +60,38 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [showNotification]);
|
}, [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]);
|
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 = () => {
|
const handleNext = () => {
|
||||||
setCursorStack(prev => [...prev, currentCursor]);
|
setCursorStack(prev => [...prev, currentCursor]);
|
||||||
setCurrentCursor(nextCursor);
|
setCurrentCursor(nextCursor);
|
||||||
@@ -93,7 +129,37 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<Typography variant="h6">Users</Typography>
|
<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>
|
</Box>
|
||||||
|
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
@@ -140,24 +206,26 @@ const UsersTab: React.FC<UsersTabProps> = ({ rateLimits, allPermissions }) => {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
|
{!activeSearch && (
|
||||||
<Button
|
<Stack direction="row" justifyContent="flex-end" spacing={1} sx={{ mt: 2 }}>
|
||||||
startIcon={<PrevIcon />}
|
<Button
|
||||||
onClick={handlePrev}
|
startIcon={<PrevIcon />}
|
||||||
disabled={cursorStack.length === 0}
|
onClick={handlePrev}
|
||||||
size="small"
|
disabled={cursorStack.length === 0}
|
||||||
>
|
size="small"
|
||||||
Previous
|
>
|
||||||
</Button>
|
Previous
|
||||||
<Button
|
</Button>
|
||||||
endIcon={<NextIcon />}
|
<Button
|
||||||
onClick={handleNext}
|
endIcon={<NextIcon />}
|
||||||
disabled={!hasMore}
|
onClick={handleNext}
|
||||||
size="small"
|
disabled={!hasMore}
|
||||||
>
|
size="small"
|
||||||
Next
|
>
|
||||||
</Button>
|
Next
|
||||||
</Stack>
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<UserDetailDialog
|
<UserDetailDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const adminService = {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
}).then(r => r.json()) as Promise<PaginatedUsers>,
|
}).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) =>
|
getUser: (id: number) =>
|
||||||
fetchData<AdminUser>(`/admin/user/${id}`),
|
fetchData<AdminUser>(`/admin/user/${id}`),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user