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 (
+ +
+
+

Allow Public Signups

+

+ When disabled, new users cannot sign up via API or OAuth. Only existing users can log in. +

+
+ +
+ setIsSocialAuthModalOpen(true)} diff --git a/mintlify/docs/guides/authentication.mdx b/mintlify/docs/guides/authentication.mdx index e154579f5..cae39b3f2 100644 --- a/mintlify/docs/guides/authentication.mdx +++ b/mintlify/docs/guides/authentication.mdx @@ -9,6 +9,15 @@ All auth endpoints require your publishable key (`pk_live_...`) in the `x-api-ke **Base URL:** `https://api.ub.bitbros.in` +## Disabling Public Signups + +By default, any user can create an account. To block new registrations: +1. Navigate to your project dashboard. +2. Go to the **Authentication** page. +3. Turn off the **Allow Public Signups** toggle. + +Once disabled, new signups will receive a `403 Forbidden` error, but existing users can still log in. + ## The `users` collection contract Before using authentication, create a collection named `users` in your project. It must include at least these two fields: diff --git a/mintlify/docs/react-sdk/ur-auth.mdx b/mintlify/docs/react-sdk/ur-auth.mdx index b7b9be221..62982e379 100644 --- a/mintlify/docs/react-sdk/ur-auth.mdx +++ b/mintlify/docs/react-sdk/ur-auth.mdx @@ -35,6 +35,7 @@ export default function LoginPage() { | `colors` | `Partial` | `undefined` | Overrides for auth widget theme colors. | | `branding` | `AuthBranding` | `undefined` | Branding/white-label configuration (logo, app name, subtitle, brand color). | | `labels` | `Partial` | `undefined` | Text/copy overrides for tabs, titles, buttons, and footer prompts. | +| `hideSignup` | `boolean` | `false` | Hides the sign up tab and sign up footer toggle when true. | | `onSuccess` | `() => void` | `undefined` | Called after successful sign-in or sign-up flow. | > All customization props are optional. If omitted, `` falls back to the same default behavior/UI style as v0.1.x. diff --git a/packages/common/src/models/Project.js b/packages/common/src/models/Project.js index 80c3ec7bd..89bfe8d5b 100755 --- a/packages/common/src/models/Project.js +++ b/packages/common/src/models/Project.js @@ -107,6 +107,7 @@ const projectSchema = new mongoose.Schema( required: true, }, isAuthEnabled: { type: Boolean, default: false }, + allowPublicSignup: { type: Boolean, default: true }, siteUrl: { type: String, default: "" }, /** * Managed OAuth providers for project user authentication. diff --git a/sdks/urbackend-react/README.md b/sdks/urbackend-react/README.md index 84a770d8a..7df7d03cc 100644 --- a/sdks/urbackend-react/README.md +++ b/sdks/urbackend-react/README.md @@ -41,7 +41,11 @@ import { UrAuth, GuestRoute } from '@urbackend/react'; export default function LoginPage() { return ( Loading...
} onRedirect={() => window.location.href = '/dashboard'}> - + ); } diff --git a/sdks/urbackend-react/src/components/UrAuth.tsx b/sdks/urbackend-react/src/components/UrAuth.tsx index ea6754275..5790838c3 100644 --- a/sdks/urbackend-react/src/components/UrAuth.tsx +++ b/sdks/urbackend-react/src/components/UrAuth.tsx @@ -80,6 +80,7 @@ export interface UrAuthProps { branding?: AuthBranding; labels?: Partial; onSuccess?: () => void; + hideSignup?: boolean; } const defaultLabels: AuthLabels = { @@ -153,10 +154,11 @@ export const UrAuth: React.FC = ({ colors, branding, labels, - onSuccess + onSuccess, + hideSignup = false }) => { const { login, signUp, socialLogin, requestPasswordReset, resetPassword, isLoading, error, clearError } = useAuth(); - const [mode, setMode] = useState<'signin' | 'signup' | 'forgot' | 'reset'>('signin'); + const [mode, setMode] = useState<'signin' | 'signup' | 'forgot' | 'reset'>(hideSignup ? 'signin' : 'signin'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [otp, setOtp] = useState(''); @@ -194,6 +196,8 @@ export const UrAuth: React.FC = ({ const hasPasswordAuth = isEmailPasswordEnabled; const hasSocialAuth = isGoogleEnabled || isGithubEnabled; + + const showSwitcher = hasPasswordAuth && !hideSignup; const brandName = branding?.brandName || branding?.appName || branding?.title || 'urBackend'; const headerTitle = branding?.title || brandName; const brandingLogo = branding?.logo ?? branding?.logoUrl; @@ -204,7 +208,6 @@ export const UrAuth: React.FC = ({ : mode === 'forgot' ? text.forgotTitle : text.resetTitle); - const showSwitcher = hasPasswordAuth; useEffect(() => { if (error) { @@ -651,7 +654,7 @@ export const UrAuth: React.FC = ({ {(mode === 'signin' || mode === 'signup') && renderSocialButtons()} - {hasPasswordAuth && ( + {hasPasswordAuth && (!hideSignup || mode !== 'signin') && (
{footerPrompt}