Skip to content

feat(accounts): add SIGNUP_MODE to close or restrict registration - #141

Open
johannes-steidle wants to merge 1 commit into
brightbeanxyz:mainfrom
johannes-steidle:feat/signup-mode
Open

feat(accounts): add SIGNUP_MODE to close or restrict registration#141
johannes-steidle wants to merge 1 commit into
brightbeanxyz:mainfrom
johannes-steidle:feat/signup-mode

Conversation

@johannes-steidle

@johannes-steidle johannes-steidle commented Aug 9, 2026

Copy link
Copy Markdown

What does this PR do?

Adds a SIGNUP_MODE setting that decides who may create an account. It takes one of three values and defaults to open, so nothing changes for existing deployments:

Value Who may create an account
open (default) Anyone.
invite Only visitors who followed a valid invitation link.
closed Nobody; accounts are created in the Django admin.

Both doors are covered. Guarding the email form alone would achieve nothing: SOCIALACCOUNT_AUTO_SIGNUP creates the account during the provider callback, so a new Google user never visits the signup page at all. The decision therefore lives in one helper, signup_allowed(), used by both a new AccountAdapter and the existing SocialAccountAdapter.

Invite mode reuses the invitation the visitor already carries in the session — apps.members.views puts the token there, apps.accounts.signals consumes it after signup — and requires it to be neither accepted nor expired, so a single link cannot mint accounts indefinitely.

An unknown value raises ImproperlyConfigured at startup instead of falling back to a default: a typo that silently leaves signup open is the failure mode worth being loud about.

The login page hides its "Sign up" link when the visitor could not sign up anyway, and keeps showing it in invite mode for visitors who followed an invitation. signup_closed.html matches the other entrance pages.

Signing in is never affected. An existing user reaching the app through a social login is connected by email rather than signed up, so the gate is not consulted.

Why?

A self-hosted install has to be reachable from the internet — that is where the platforms deliver OAuth callbacks and webhooks — so an open signup form is open to everyone who finds the domain. There was no way to turn it off short of putting a rule in the reverse proxy, which also blocks invited users.

How to test

Automated:

pytest apps/accounts/tests/test_signup_mode.py

Covers all three modes across the email form and the social callback, the invite-mode rejections (expired, already accepted, unknown token), that an existing user can still log in with closed, and that the login page shows or hides its "Sign up" link accordingly.

Manually, with SIGNUP_MODE=closed in .env:

  1. /accounts/signup/ renders the closed notice instead of the form, and the "Sign up" link is gone from /accounts/login/.
  2. An existing account still logs in normally, by password and through Google.
  3. A Google account that has never signed in here does not get created by the callback.

Then set SIGNUP_MODE=invite and restart:

  1. /accounts/signup/ is still closed for a plain visitor.
  2. Invite an address under Settings → Members, follow the link from the mail, and the same page now accepts the signup — by email form and through Google.
  3. Follow that same link again after the account exists: it is accepted, so signup is refused.

Finally, SIGNUP_MODE=nonsense must abort at startup with ImproperlyConfigured rather than boot.

Checklist

  • Tests pass (pytest) — 1393 passed
  • Lint passes (ruff check . and ruff format --check .)
  • Documentation updated (if applicable) — README "Restricting Sign-up" section and .env.example

A self-hosted install has to be reachable from the internet — that is
where the platforms deliver OAuth callbacks and webhooks — so an open
signup form is open to everyone who finds the domain. There was no way to
turn it off short of putting a rule in the reverse proxy, which also
blocks invited users.

SIGNUP_MODE takes one of three values and defaults to "open", so nothing
changes for existing deployments:

  open    anyone can sign up
  invite  only visitors who followed a valid invitation link
  closed  nobody; accounts are created in the Django admin

Both doors are covered. Guarding the email form alone would achieve
nothing: SOCIALACCOUNT_AUTO_SIGNUP creates the account during the provider
callback, so a new Google user never visits the signup page at all. The
decision therefore lives in one helper used by both a new AccountAdapter
and the existing SocialAccountAdapter.

Invite mode reuses the invitation the visitor already carries in the
session — apps.members.views puts the token there, apps.accounts.signals
consumes it after signup — and requires it to be neither accepted nor
expired, so a single link cannot mint accounts indefinitely.

An unknown value raises ImproperlyConfigured at startup instead of falling
back to a default: a typo that silently leaves signup open is the failure
mode worth being loud about.

The login page hides its "Sign up" link when the visitor could not sign up
anyway, and keeps showing it in invite mode for visitors who followed an
invitation. signup_closed.html matches the other entrance pages.

Signing in is never affected. An existing user reaching the app through a
social login is connected by email rather than signed up, so the gate is
not consulted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johannes-steidle

Copy link
Copy Markdown
Author

Friendly ping on this one — no rush, I know the queue is busy.

Status: still merges cleanly on current main, no conflicts. The only thing blocking CI is the first-time-contributor approval gate, so the workflow hasn't run here yet; it passed locally (1393 tests, ruff check and ruff format --check clean).

Happy to rebase, split it up, or change the approach if the design doesn't fit where you want signup handling to live. And if it's simply not something you want in core, that's a fine answer too — I'll keep it in my own fork.

For what it's worth, someone else has already picked the patch up for their self-hosted install, so there seems to be some demand for closing signups on a public-facing deployment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant