diff --git a/README.md b/README.md index a9768c54b..315f3b05c 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,10 @@ const post = await client.db.insert('posts', { | `403 Insert denied` (ownerField `_id`) | `_id` is not a valid owner field for inserts | Change ownerField to `userId` or similar | | `403 Owner field immutable` | Trying to change the owner field on update | Remove the owner field from the PATCH/PUT body | +## Public Signup + +By default, new users can sign up using email/password or social providers. You can disable public signups from the **Authentication** page in the dashboard under the **Allow Public Signups** toggle. When disabled, the API blocks new registrations, allowing only existing users to log in. + --- ## Social Auth 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 b16ef5531..42a9d151f 100644 --- a/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js +++ b/apps/dashboard-api/src/__tests__/routes.projects.storage.test.js @@ -68,6 +68,7 @@ jest.mock('../controllers/project.controller', () => { analytics: jest.fn(ok), updateAllowedDomains: jest.fn(ok), toggleAuth: jest.fn(ok), + togglePublicSignup: jest.fn(ok), updateAuthProviders: jest.fn(ok), updateCollectionRls: jest.fn(ok), listMailTemplates: jest.fn(ok), diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index 695d3ff44..37b0f8de2 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -2553,6 +2553,43 @@ module.exports.toggleAuth = async (req, res) => { } }; +module.exports.togglePublicSignup = async (req, res, next) => { + try { + const { projectId } = req.params; + const { enable } = req.body; + + if (typeof enable !== "boolean") { + return next(new AppError(400, "enable parameter must be a boolean")); + } + + const project = await Project.findOne({ + _id: projectId, + ...getProjectAccessQuery(req.user._id), + }); + if (!project) return next(new AppError(404, "Project not found")); + + project.allowPublicSignup = enable; + await project.save(); + + await deleteProjectById(projectId); + await deleteProjectByApiKeyCache(project.publishableKey); + await deleteProjectByApiKeyCache(project.secretKey); + + const projectObj = sanitizeProjectResponse(project.toObject()); + + res.json({ + success: true, + data: { + allowPublicSignup: project.allowPublicSignup, + project: projectObj, + }, + message: `Public signup ${project.allowPublicSignup ? "enabled" : "disabled"} successfully` + }); + } catch (err) { + next(err); + } +}; + module.exports.updateAuthProviders = async (req, res) => { try { const { projectId } = req.params; diff --git a/apps/dashboard-api/src/routes/projects.js b/apps/dashboard-api/src/routes/projects.js index 45706716d..b66377c13 100644 --- a/apps/dashboard-api/src/routes/projects.js +++ b/apps/dashboard-api/src/routes/projects.js @@ -31,6 +31,7 @@ const { analytics, updateAllowedDomains, toggleAuth, + togglePublicSignup, updateAuthProviders, updateCollectionRls, listMailTemplates, @@ -138,6 +139,7 @@ router.get('/:projectId/analytics', authMiddleware, authorizeProject(), analytic // PATCH REQ FOR TOGGLE AUTH router.patch('/:projectId/auth/toggle', authMiddleware, authorizeProject('admin'), verifyEmail, toggleAuth); +router.patch('/:projectId/auth/public-signup', authMiddleware, authorizeProject('admin'), verifyEmail, togglePublicSignup); // PATCH REQ FOR SOCIAL AUTH PROVIDERS router.patch('/:projectId/auth/providers', authMiddleware, authorizeProject('admin'), planEnforcement.attachDeveloper, verifyEmail, planEnforcement.checkByokGate, updateAuthProviders); diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 12eb13d13..bd53d850d 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -617,6 +617,10 @@ const findOrCreateSocialUser = async ({ project, usersColConfig, Model, provider return { user, isNewUser: false, linkedByEmail: true }; } + if (project.allowPublicSignup === false) { + throw new AppError(403, "Public signup is disabled for this project. Only existing users can log in."); + } + const newUserPayload = await buildSocialAuthUserPayload(usersColConfig, profile); newUserPayload[providerIdField] = profile.providerUserId; newUserPayload.authProviders = [providerName]; @@ -988,6 +992,10 @@ module.exports.signup = async (req, res, next) => { try { const project = req.project; + if (project.allowPublicSignup === false) { + return next(new AppError(403, "Public signup is disabled for this project")); + } + const { email, password, username, ...otherData } = userSignupSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); diff --git a/apps/web-dashboard/src/pages/Auth.jsx b/apps/web-dashboard/src/pages/Auth.jsx index 5970a5c68..69c4a2d47 100644 --- a/apps/web-dashboard/src/pages/Auth.jsx +++ b/apps/web-dashboard/src/pages/Auth.jsx @@ -34,6 +34,7 @@ export default function Auth() { const [searchTerm, setSearchTerm] = useState(''); const [project, setProject] = useState(null); const [isEnabling, setIsEnabling] = useState(false); + const [isTogglingSignup, setIsTogglingSignup] = useState(false); const [isSavingProviders, setIsSavingProviders] = useState(false); const [isSocialAuthModalOpen, setIsSocialAuthModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -60,6 +61,13 @@ export default function Auth() { const githubCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/github/callback`; const googleCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/google/callback`; + const myMember = project?.members?.find(m => { + const memberId = typeof m.user === 'object' ? m.user?._id : m.user; + return memberId?.toString() === user?._id?.toString() || m.email === user?.email; + }); + const myRole = project?.owner?.toString() === user?._id?.toString() ? 'owner' : (myMember?.role || 'viewer'); + const isViewer = myRole === 'viewer'; + // Flow Logic const usersCollection = project?.collections?.find(c => c.name === 'users'); const hasUserCollection = !!usersCollection; @@ -148,6 +156,20 @@ export default function Auth() { finally { setIsSavingProviders(false); } }; + const handleTogglePublicSignup = async (enable) => { + if (isTogglingSignup || isViewer) return; + setIsTogglingSignup(true); + try { + const res = await api.patch(`/api/projects/${projectId}/auth/public-signup`, { enable }); + setProject(res.data.project); + toast.success(`Public signup ${enable ? 'enabled' : 'disabled'}`); + } catch (err) { + toast.error(err.response?.data?.message || 'Failed to toggle public signup'); + } finally { + setIsTogglingSignup(false); + } + }; + const goToUsersSchemaPreset = () => { navigate(`/project/${projectId}/create-collection?name=users&preset=auth-users`); }; @@ -210,13 +232,6 @@ export default function Auth() { if (loading) return
; - const myMember = project?.members?.find(m => { - const memberId = typeof m.user === 'object' ? m.user?._id : m.user; - return memberId?.toString() === user?._id?.toString() || m.email === user?.email; - }); - const myRole = project?.owner?.toString() === user?._id?.toString() ? 'owner' : (myMember?.role || 'viewer'); - const isViewer = myRole === 'viewer'; - return (+ When disabled, new users cannot sign up via API or OAuth. Only existing users can log in. +
+