diff --git a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js index a0d550a3..3cbc5c31 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -28,6 +28,8 @@ jest.mock('../controllers/userAuth.controller', () => ({ resetPassword: jest.fn((_req, res) => res.json({ ok: true })), getUserDetails: jest.fn((_req, res) => res.json({ ok: true })), updateAdminUser: jest.fn((_req, res) => res.json({ ok: true })), + listAdminUsers: jest.fn((_req, res) => res.json({ ok: true })), + deleteAdminUser: jest.fn((_req, res) => res.json({ ok: true })), listUserSessions: jest.fn((_req, res) => res.json({ ok: true })), revokeUserSession: jest.fn((_req, res) => res.json({ ok: true })), })); diff --git a/apps/dashboard-api/src/controllers/userAuth.controller.js b/apps/dashboard-api/src/controllers/userAuth.controller.js index 90ede0a3..8a1c0f7a 100644 --- a/apps/dashboard-api/src/controllers/userAuth.controller.js +++ b/apps/dashboard-api/src/controllers/userAuth.controller.js @@ -8,7 +8,13 @@ const { authEmailQueue } = require('@urbackend/common'); const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); const { getCompiledModel } = require('@urbackend/common'); -const { getUserActiveSessions, getRefreshSession, revokeSessionChain } = require('@urbackend/common'); +const { + AppError, + ApiResponse, + getUserActiveSessions, + getRefreshSession, + revokeSessionChain +} = require('@urbackend/common'); const hasRequiredField = (usersColConfig, fieldKey) => { const model = usersColConfig?.model || []; @@ -238,6 +244,62 @@ module.exports.createAdminUser = async (req, res) => { } } +module.exports.listAdminUsers = async (req, res, next) => { + try { + const project = req.project; + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const page = Math.max(1, parseInt(req.query.page, 10) || 1); + const limit = Math.max(1, Math.min(parseInt(req.query.limit, 10) || 50, 100)); + const skip = (page - 1) * limit; + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const [items, total] = await Promise.all([ + Model.find({}, { password: 0 }).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(), + Model.countDocuments() + ]); + + return new ApiResponse( + { items, total, page, limit }, + "" + ).send(res); + } catch (err) { + next(new AppError(500, "Failed to list admin users")); + } +}; + +module.exports.deleteAdminUser = async (req, res, next) => { + try { + const project = req.project; + const { userId } = req.params; + + if (!mongoose.Types.ObjectId.isValid(userId)) { + return next(new AppError(400, "Invalid user ID")); + } + + const usersColConfig = project.collections.find(c => c.name === 'users'); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); + + const connection = await getConnection(project._id); + const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); + + const result = await Model.deleteOne({ _id: new mongoose.Types.ObjectId(userId) }); + if (result.deletedCount === 0) { + return next(new AppError(404, "User not found")); + } + + return new ApiResponse( + null, + "User deleted successfully" + ).send(res); + } catch (err) { + next(new AppError(500, "Failed to delete admin user")); + } +}; + // PATCH REQ FOR ADMIN RESET PASSWORD module.exports.resetPassword = async (req, res) => { try { diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 0e42ba92..06c8d4cf 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -48,7 +48,7 @@ const { sendMarketingBroadcast } = require("../controllers/project.controller"); -const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller'); +const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listAdminUsers, deleteAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller'); const exportController = require('../controllers/dbExport.controller'); @@ -146,8 +146,10 @@ router.patch('/:projectId/collections/:collectionName/rls', authMiddleware, veri router.post('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, createAdminUser); router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadProjectForAdmin, checkAuthEnabled, resetPassword); +router.get('/:projectId/admin/users', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listAdminUsers); router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails); router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser); +router.delete('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, deleteAdminUser); // SESSION MANAGEMENT (Admin) router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions); diff --git a/apps/web-dashboard/src/pages/Auth.jsx b/apps/web-dashboard/src/pages/Auth.jsx index 071c2309..e899812f 100644 --- a/apps/web-dashboard/src/pages/Auth.jsx +++ b/apps/web-dashboard/src/pages/Auth.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import api from '../utils/api'; import toast from 'react-hot-toast'; @@ -8,6 +8,7 @@ import AuthHeader from '../components/Auth/AuthHeader'; import SocialAuthConfig from '../components/Auth/SocialAuthConfig'; import SocialAuthModal from '../components/Auth/SocialAuthModal'; import UserTable from '../components/Auth/UserTable'; +import Pagination from '../components/Database/Pagination'; import SectionHeader from '../components/Dashboard/SectionHeader'; import AddRecordDrawer from '../components/AddRecordDrawer'; import { PUBLIC_API_URL } from '../config'; @@ -24,6 +25,9 @@ export default function Auth() { const navigate = useNavigate(); const [users, setUsers] = useState([]); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(50); + const [totalRecords, setTotalRecords] = useState(0); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [project, setProject] = useState(null); @@ -32,6 +36,7 @@ export default function Auth() { const [isSocialAuthModalOpen, setIsSocialAuthModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); // user being edited + const latestUsersRequestId = useRef(0); const [selectedProvider, setSelectedProvider] = useState('github'); const [authProviders, setAuthProviders] = useState({ github: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false }, @@ -76,8 +81,18 @@ export default function Auth() { setProject(projRes.data); if (projRes.data.authProviders) setAuthProviders(projRes.data.authProviders); if (projRes.data.isAuthEnabled) { - const usersRes = await api.get(`/api/projects/${projectId}/collections/users/data`); + const requestId = ++latestUsersRequestId.current; + const usersRes = await api.get( + `/api/projects/${projectId}/admin/users?page=${page}&limit=${limit}` + ); + + if (!isMounted || requestId !== latestUsersRequestId.current) return; setUsers(normalizeUsersResponse(usersRes.data)); + setTotalRecords( + usersRes.data?.data?.total || + usersRes.data?.total || + normalizeUsersResponse(usersRes.data).length + ); } } } catch { toast.error("Failed to load auth details"); } @@ -85,7 +100,7 @@ export default function Auth() { }; fetchData(); return () => { isMounted = false; }; - }, [projectId]); + }, [projectId, page, limit]); const handleEnableAuth = async () => { if (!hasUserCollection) return toast.error("Please create a 'users' collection first."); @@ -154,8 +169,20 @@ export default function Auth() { const handleDeleteUser = async (userId) => { if (!confirm('Delete this user? This cannot be undone.')) return; try { - await api.delete(`/api/projects/${projectId}/collections/users/data/${userId}`); - setUsers(prev => normalizeUsersResponse(prev).filter(u => u._id !== userId)); + await api.delete(`/api/projects/${projectId}/admin/users/${userId}`); + setUsers(prevUsers => { + const nextUsers = normalizeUsersResponse(prevUsers).filter( + u => u._id !== userId + ); + + if (nextUsers.length === 0) { + setPage(prevPage => (prevPage > 1 ? prevPage - 1 : prevPage)); + } + + return nextUsers; + }); + + setTotalRecords(prev => Math.max(prev - 1, 0)); toast.success('User deleted'); } catch (err) { toast.error(err.response?.data?.message || err.response?.data?.error || 'Failed to delete user'); @@ -222,8 +249,18 @@ export default function Auth() { } else { await api.post(`/api/projects/${projectId}/admin/users`, userData); toast.success('User created successfully'); - const usersRes = await api.get(`/api/projects/${projectId}/collections/users/data`); + const requestId = ++latestUsersRequestId.current; + const usersRes = await api.get( + `/api/projects/${projectId}/admin/users?page=${page}&limit=${limit}` + ); + + if (requestId !== latestUsersRequestId.current) return; setUsers(normalizeUsersResponse(usersRes.data)); + setTotalRecords( + usersRes.data?.data?.total || + usersRes.data?.total || + normalizeUsersResponse(usersRes.data).length + ); } setIsAddModalOpen(false); setEditingUser(null); @@ -295,6 +332,16 @@ export default function Auth() { onResetPassword={(u) => { setResetPasswordUser(u); setNewPassword(''); }} onDelete={handleDeleteUser} /> + setPage(p)} + onLimitChange={(newLimit) => { + setLimit(newLimit); + setPage(1); + }} + />