Skip to content

Commit 5c739a6

Browse files
feat(auth): add pagination support to Auth user list (#300)
* feat(auth): add pagination support to user list * fix(auth): prevent stale pagination updates and sync totals * fix(auth): address stale state handling in delete flow * feat(auth): add admin user pagination endpoints * test(auth): mock new admin user routes * fix(auth): align admin endpoints with API conventions * refactor(auth): use ApiResponse for admin user endpoints
1 parent 2d42729 commit 5c739a6

4 files changed

Lines changed: 121 additions & 8 deletions

File tree

apps/dashboard-api/src/__tests__/routes.projects.storage.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ jest.mock('../controllers/userAuth.controller', () => ({
2828
resetPassword: jest.fn((_req, res) => res.json({ ok: true })),
2929
getUserDetails: jest.fn((_req, res) => res.json({ ok: true })),
3030
updateAdminUser: jest.fn((_req, res) => res.json({ ok: true })),
31+
listAdminUsers: jest.fn((_req, res) => res.json({ ok: true })),
32+
deleteAdminUser: jest.fn((_req, res) => res.json({ ok: true })),
3133
listUserSessions: jest.fn((_req, res) => res.json({ ok: true })),
3234
revokeUserSession: jest.fn((_req, res) => res.json({ ok: true })),
3335
}));

apps/dashboard-api/src/controllers/userAuth.controller.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ const { authEmailQueue } = require('@urbackend/common');
88
const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common');
99
const { getConnection } = require('@urbackend/common');
1010
const { getCompiledModel } = require('@urbackend/common');
11-
const { getUserActiveSessions, getRefreshSession, revokeSessionChain } = require('@urbackend/common');
11+
const {
12+
AppError,
13+
ApiResponse,
14+
getUserActiveSessions,
15+
getRefreshSession,
16+
revokeSessionChain
17+
} = require('@urbackend/common');
1218

1319
const hasRequiredField = (usersColConfig, fieldKey) => {
1420
const model = usersColConfig?.model || [];
@@ -238,6 +244,62 @@ module.exports.createAdminUser = async (req, res) => {
238244
}
239245
}
240246

247+
module.exports.listAdminUsers = async (req, res, next) => {
248+
try {
249+
const project = req.project;
250+
const usersColConfig = project.collections.find(c => c.name === 'users');
251+
if (!usersColConfig) return next(new AppError(404, "Auth collection not found"));
252+
253+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
254+
const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 100));
255+
const skip = (page - 1) * limit;
256+
257+
const connection = await getConnection(project._id);
258+
const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal);
259+
260+
const [items, total] = await Promise.all([
261+
Model.find({}, { password: 0 }).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
262+
Model.countDocuments()
263+
]);
264+
265+
return new ApiResponse(
266+
{ items, total, page, limit },
267+
""
268+
).send(res);
269+
} catch (err) {
270+
next(new AppError(500, "Failed to list admin users"));
271+
}
272+
};
273+
274+
module.exports.deleteAdminUser = async (req, res, next) => {
275+
try {
276+
const project = req.project;
277+
const { userId } = req.params;
278+
279+
if (!mongoose.Types.ObjectId.isValid(userId)) {
280+
return next(new AppError(400, "Invalid user ID"));
281+
}
282+
283+
const usersColConfig = project.collections.find(c => c.name === 'users');
284+
if (!usersColConfig) return next(new AppError(404, "Auth collection not found"));
285+
286+
const connection = await getConnection(project._id);
287+
const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal);
288+
289+
const result = await Model.deleteOne({ _id: new mongoose.Types.ObjectId(userId) });
290+
if (result.deletedCount === 0) {
291+
return next(new AppError(404, "User not found"));
292+
}
293+
294+
return new ApiResponse(
295+
null,
296+
"User deleted successfully"
297+
).send(res);
298+
} catch (err) {
299+
next(new AppError(500, "Failed to delete admin user"));
300+
}
301+
};
302+
241303
// PATCH REQ FOR ADMIN RESET PASSWORD
242304
module.exports.resetPassword = async (req, res) => {
243305
try {

apps/dashboard-api/src/routes/projects.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const {
4848
sendMarketingBroadcast
4949
} = require("../controllers/project.controller");
5050

51-
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
51+
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
5252

5353
const exportController = require('../controllers/dbExport.controller');
5454

@@ -146,8 +146,10 @@ router.patch('/:projectId/collections/:collectionName/rls', authMiddleware, veri
146146

147147
router.post('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, createAdminUser);
148148
router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadProjectForAdmin, checkAuthEnabled, resetPassword);
149+
router.get('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listAdminUsers);
149150
router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails);
150151
router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser);
152+
router.delete('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, deleteAdminUser);
151153

152154
// SESSION MANAGEMENT (Admin)
153155
router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions);

apps/web-dashboard/src/pages/Auth.jsx

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useRef } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
33
import api from '../utils/api';
44
import toast from 'react-hot-toast';
@@ -8,6 +8,7 @@ import AuthHeader from '../components/Auth/AuthHeader';
88
import SocialAuthConfig from '../components/Auth/SocialAuthConfig';
99
import SocialAuthModal from '../components/Auth/SocialAuthModal';
1010
import UserTable from '../components/Auth/UserTable';
11+
import Pagination from '../components/Database/Pagination';
1112
import SectionHeader from '../components/Dashboard/SectionHeader';
1213
import AddRecordDrawer from '../components/AddRecordDrawer';
1314
import { PUBLIC_API_URL } from '../config';
@@ -24,6 +25,9 @@ export default function Auth() {
2425
const navigate = useNavigate();
2526

2627
const [users, setUsers] = useState([]);
28+
const [page, setPage] = useState(1);
29+
const [limit, setLimit] = useState(50);
30+
const [totalRecords, setTotalRecords] = useState(0);
2731
const [loading, setLoading] = useState(true);
2832
const [searchTerm, setSearchTerm] = useState('');
2933
const [project, setProject] = useState(null);
@@ -32,6 +36,7 @@ export default function Auth() {
3236
const [isSocialAuthModalOpen, setIsSocialAuthModalOpen] = useState(false);
3337
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
3438
const [editingUser, setEditingUser] = useState(null); // user being edited
39+
const latestUsersRequestId = useRef(0);
3540
const [selectedProvider, setSelectedProvider] = useState('github');
3641
const [authProviders, setAuthProviders] = useState({
3742
github: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false },
@@ -76,16 +81,26 @@ export default function Auth() {
7681
setProject(projRes.data);
7782
if (projRes.data.authProviders) setAuthProviders(projRes.data.authProviders);
7883
if (projRes.data.isAuthEnabled) {
79-
const usersRes = await api.get(`/api/projects/${projectId}/collections/users/data`);
84+
const requestId = ++latestUsersRequestId.current;
85+
const usersRes = await api.get(
86+
`/api/projects/${projectId}/admin/users?page=${page}&limit=${limit}`
87+
);
88+
89+
if (!isMounted || requestId !== latestUsersRequestId.current) return;
8090
setUsers(normalizeUsersResponse(usersRes.data));
91+
setTotalRecords(
92+
usersRes.data?.data?.total ||
93+
usersRes.data?.total ||
94+
normalizeUsersResponse(usersRes.data).length
95+
);
8196
}
8297
}
8398
} catch { toast.error("Failed to load auth details"); }
8499
finally { if (isMounted) setLoading(false); }
85100
};
86101
fetchData();
87102
return () => { isMounted = false; };
88-
}, [projectId]);
103+
}, [projectId, page, limit]);
89104

90105
const handleEnableAuth = async () => {
91106
if (!hasUserCollection) return toast.error("Please create a 'users' collection first.");
@@ -154,8 +169,20 @@ export default function Auth() {
154169
const handleDeleteUser = async (userId) => {
155170
if (!confirm('Delete this user? This cannot be undone.')) return;
156171
try {
157-
await api.delete(`/api/projects/${projectId}/collections/users/data/${userId}`);
158-
setUsers(prev => normalizeUsersResponse(prev).filter(u => u._id !== userId));
172+
await api.delete(`/api/projects/${projectId}/admin/users/${userId}`);
173+
setUsers(prevUsers => {
174+
const nextUsers = normalizeUsersResponse(prevUsers).filter(
175+
u => u._id !== userId
176+
);
177+
178+
if (nextUsers.length === 0) {
179+
setPage(prevPage => (prevPage > 1 ? prevPage - 1 : prevPage));
180+
}
181+
182+
return nextUsers;
183+
});
184+
185+
setTotalRecords(prev => Math.max(prev - 1, 0));
159186
toast.success('User deleted');
160187
} catch (err) {
161188
toast.error(err.response?.data?.message || err.response?.data?.error || 'Failed to delete user');
@@ -222,8 +249,18 @@ export default function Auth() {
222249
} else {
223250
await api.post(`/api/projects/${projectId}/admin/users`, userData);
224251
toast.success('User created successfully');
225-
const usersRes = await api.get(`/api/projects/${projectId}/collections/users/data`);
252+
const requestId = ++latestUsersRequestId.current;
253+
const usersRes = await api.get(
254+
`/api/projects/${projectId}/admin/users?page=${page}&limit=${limit}`
255+
);
256+
257+
if (requestId !== latestUsersRequestId.current) return;
226258
setUsers(normalizeUsersResponse(usersRes.data));
259+
setTotalRecords(
260+
usersRes.data?.data?.total ||
261+
usersRes.data?.total ||
262+
normalizeUsersResponse(usersRes.data).length
263+
);
227264
}
228265
setIsAddModalOpen(false);
229266
setEditingUser(null);
@@ -295,6 +332,16 @@ export default function Auth() {
295332
onResetPassword={(u) => { setResetPasswordUser(u); setNewPassword(''); }}
296333
onDelete={handleDeleteUser}
297334
/>
335+
<Pagination
336+
total={totalRecords}
337+
page={page}
338+
limit={limit}
339+
onPageChange={(p) => setPage(p)}
340+
onLimitChange={(newLimit) => {
341+
setLimit(newLimit);
342+
setPage(1);
343+
}}
344+
/>
298345
</div>
299346
</div>
300347

0 commit comments

Comments
 (0)