feat: add RequireInvitation registration mode for invite-link-style signups#6450
feat: add RequireInvitation registration mode for invite-link-style signups#6450kryoseu wants to merge 19 commits intoLemmyNet:mainfrom
Conversation
dd73567 to
f2e0575
Compare
|
Very nice that you're taking this on! So the way your code works right now is that there exists only a single, global registration token. This token can only be seen by admins, who then share it with others. It can also be used an unlimited amount of times, so if someone gets a registration token, he can register an unlimited amount of accounts. Instead I would make it more similar to lobsters or Mastodon: Essentially:
Optional, can be added later:
|
Nice, thanks for the detailed info, that helps. In the approach you suggested, my understanding is that anyone would be able to create invitation links, not just admins, right? Also, should invitation links work regardless of the registration mode (Open, Close, Require application) selected by the admin or should we add a new registration mode like Registration link, and only in which case are invitation links accepted? |
dc5d1b6 to
61266f6
Compare
Yes exactly.
Good question, I asked about that in the issue. |
The latter IMO. This should be a 4th type (exclusive from the others), called something like The # of uses stuff is tricky. Personally I think it'd be easier to only have an expiration link (and maybe even just a default of a week), and make sure that the
Agree with this also, every user should be able to create invite links. The |
|
This is the schema I have so far. New table:
Update local_site registration modeRegister usersUsing invite Invite switches to Local user has a new column showing who it was invited by: At the moment I have an hourly job |
You dont need to store the full invite url in the db, its enough to store the token and concatenate when returning or validating. Either way its not much difference.
Call it
Storing the status seems unnecessary. You can calculate it on demand based on the expiration date and usage count (eg when registering a new user or when returning the list of invites). |
I had it that way, but thought storing them as links directly would help avoid having to iterate over all user invites to return in a list call or the front end having to form the URL.
Ack.
Imo it's the more natural thing to do, rather than computing their statuses for all invites from a user in every list call. |
It requires an extra scheduled task which is not really necessary. And the status will be wrong when the scheduled task did not run yet. Anyway Regardless you should push the code that you have so far, its better to review that way. |
035feb4 to
0f257da
Compare
| #[default] | ||
| Own, | ||
| /// all invitations, admin only | ||
| All, |
There was a problem hiding this comment.
Not sure on this tbh. Not sure it makes sense to allow admins to list all invites, unless we add an option for admins to be able to revoke any invite.
There was a problem hiding this comment.
Yes this doesnt make so much sense, even on a small instance you would see hundreds of invites. Makes more sense to list invites for a specific user.
In any case I would leave all out the advanced admin functionality from this PR (except basic setting to enable invite mode). Lets finish the basic backend first, then implement the ui and after that think which extra admin features make sense.
| /// Data for loading Lemmy plugins | ||
| pub plugins: Vec<PluginSettings>, | ||
| /// How many active invite links a user can have | ||
| pub max_invites_per_user_allowed: Option<u16>, |
There was a problem hiding this comment.
This kind of setting should go into the local_site table, so it can be changed through the admin ui without restart.
There was a problem hiding this comment.
Yeah, that makes more sense.
| 'Revoked', | ||
| 'Exhausted', | ||
| 'Expired' | ||
| ); |
There was a problem hiding this comment.
Again the status is unnecessary, because any invalid tokens can be directly deleted from the db. And registration needs to check expires_at against current time, because the scheduled task to delete expired items might not have run yet.
| #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] | ||
| pub struct CreateInvitationResponse { | ||
| pub invite_link: Url, | ||
| } |
There was a problem hiding this comment.
This response isnt useful, clients would have to make another api call to update the list of invites. So better to return PagedResponse<LocalUserInviteView> from the create_invitation api call.
There was a problem hiding this comment.
So you're saying the create_invitation should return a list of invites? So it would function similarly to the list_invites endpoint, plus the newly created invite.
There was a problem hiding this comment.
Actually not, other create calls return a view for the object that was created. For example create_post returns PostResponse which contains PostView so it should be similar here.
|
Nice, thanks guys, lots of stuff to address, but I appreciate the comments. |
Instead of tracking Active/Revoked/Exhausted/Expired via a status column invites are simply deleted when revoked, consumed, or expired. This removes `LocalUserInviteStatus`, `LocalUserInviteInsertForm`.status, and `InvitationListingType` from the schema and simplifies the query and API layers.
Return the full `LocalUserInvite` object from create/list endpoints instead of a pre-computed `invite_link` URL, letting clients construct the link themselves. Simplify list_invitations to pass `PagedResponse` through directly without an intermediate view wrapper.
| data: Query<ListInvitations>, | ||
| context: Data<LemmyContext>, | ||
| local_user_view: LocalUserView, | ||
| ) -> LemmyResult<Json<PagedResponse<LocalUserInvite>>> { |
There was a problem hiding this comment.
I'm returning PagedResponse<LocalUserInvite>, which works and looks clean IMO. But need input from reviewers as it was mentioned about returning LocalUserInviteView instead, which I removed entirely in 465f05a. My understanding is that views are used when we need to join tables, which I don't think it's required for invites.
Replaces the settings.hjson config field with a proper `local_site` column so admins can configure the invite limit via the site API (CreateSite/EditSite) without restarting the server. - Add `max_invites_per_user_allowed` column to `local_site` (default 10) - Expose field in `CreateSite` and `EditSite` API structs - Read limit from DB in `create_invitation` instead of app settings - Remove `max_invites_per_user_allowed` from `Settings` struct - Update migration up/down SQL accordingly
|
Hey comrades, still going through the feedback, but I have made some changes and implemented some of the things you suggested. Please take a look and review when you have time. Let me know, thanks. |
- Change revoke_invitation from POST /invite/revoke to DELETE invite - Move list_invitations from /invites to /invite/list - Add lemmy_db_views_local_user_invite and uuid/base64 dependencies to Cargo.lock
| async fn delete_invitations_when_expired(pool: &mut DbPool<'_>) -> LemmyResult<()> { | ||
| let conn = &mut get_conn(pool).await?; | ||
| diesel::delete( | ||
| local_user_invite::table.filter(local_user_invite::expires_at.lt(now().nullable())), |
There was a problem hiding this comment.
Here you can also delete rows where max_uses >= usage_count.
There was a problem hiding this comment.
We're already deleting the tokens at registration time when invite.uses_count + 1 >= invite.max_uses. Adding it here would be redundant as such cases won't exist.
| ALTER TYPE registration_mode_enum | ||
| ADD VALUE 'RequireInvitation'; | ||
|
|
||
| CREATE TABLE local_user_invite ( |
There was a problem hiding this comment.
Calling this registration_invite would be clearer in my opinion, what do you think?
There was a problem hiding this comment.
IMO I prefer local_user_invite. It follows the same convention as local_user and makes the ownership clear (an invite for a local user).
There was a problem hiding this comment.
The thing is that at a glance, I can misread local_user_invite as local_user_view. With registration_invite there is much less potential for confusion.
| .first(conn) | ||
| .await | ||
| .with_lemmy_type(LemmyErrorType::NotFound) | ||
| } |
There was a problem hiding this comment.
This method is unnecessary as tokens are unique.
| .await | ||
| .with_lemmy_type(LemmyErrorType::NotFound) | ||
| } | ||
| } |
There was a problem hiding this comment.
This method is used when revoking a user invite. If removed and delete_by_token used instead, that means any user can delete any invite, provided that they know the token, right?
There was a problem hiding this comment.
Ah youre right. What you could do is read the token first and compare the user id before deleting it with delete_by_token.
There was a problem hiding this comment.
Doable, but that means two queries instead of one. I can do that if you think it's better.
There was a problem hiding this comment.
let invite = LocalUserInvite::read_by_token(pool, &data.token).await?;
if local_user_id != invite.local_user_id {
return Err(LemmyErrorType::InvalidInviteToken.into());
}
LocalUserInvite::delete_by_token(pool, &data.token).await?;
There was a problem hiding this comment.
The problem is that async and generic code significantly increases compilation time. These two methods probably dont make much difference, but still its probably better to have less. Instead you can make a simple helper method like:
fn check_valid_invite(invite: &Invite, local_user_view: &LocalUserView) -> LemmyResult<()> {
if local_user_id != invite.local_user_id {
return Err(LemmyErrorType::InvalidInviteToken.into());
}
Ok(())
}|
Check CI, you might need to put some of the deps you added behind a feature gate: https://woodpecker.join-lemmy.org/repos/129/pipeline/20186/18 Also ping or unmark as draft when you want us to take another look. |
| query = query.filter(local_user_invite::local_user_id.eq(self.local_user_id)); | ||
|
|
||
| let paginated_query = | ||
| LocalUserInvite::paginate(query, &self.page_cursor, SortDirection::Asc, pool) |
There was a problem hiding this comment.
| LocalUserInvite::paginate(query, &self.page_cursor, SortDirection::Asc, pool) | |
| LocalUserInvite::paginate(query, &self.page_cursor, SortDirection::Desc, pool) |
| .first(conn) | ||
| .await | ||
| .with_lemmy_type(LemmyErrorType::NotFound) | ||
| } |
There was a problem hiding this comment.
This methid is only used for pagination. You can remove it and use read_by_token for pagination instead.
There was a problem hiding this comment.
Good call out, thanks.
dessalines
left a comment
There was a problem hiding this comment.
Also check merge conflicts.
|
Looked over again, and I think the admin list invites endpoint is still missing. |
I was under the impression this wouldn't add much value at this point, unless we implement APIs to allow admins to also revoke any invite, etc. If we decide to do so, I could do this in a subsequent PR, if that's okay. But imo I think more importantly would be for admins to be able to see list of users and who they were invited by. |
- Replace `is_active()` check with `is_expired()` in registration: exhausted invites are deleted on use, so only expiry needs to be checked at read time - Remove `is_active()` helper as it's no longer needed - Remove unused `read()` by ID; switch pagination cursor from integer ID to invite token via `CursorData::new_plain - Change invite list sort to descending (newest first) - Add unit and integration tests for `is_expired`, `get_invite_url`, CRUD operations, and `uses_count` updates
|
The admin functionality can be added later, otherwise this PR will get too complex. In principle this is ready to merge. However I want to hold off a bit longer, because we will publish the first beta version of 1.0.0 in the next few days. Anyway you can already start to work on the corresponding frontend changes. For that clone the lemmy-js-client repo, run |
dessalines
left a comment
There was a problem hiding this comment.
This is good for now, we might have to make tweaks as we work on the front end anyway.
As @Nutomic said, we might wait for a few days to merge this, when we plan to get a lemmy beta out (without this included).

Summary
Adds an invite-link-based registration system to Lemmy, giving instance admins a new way to control who can register.
New
RequireInvitationregistration mode — admins can set the instance to require a valid invite link for registration (alongside the existingOpen,RequireApplication, andClosedmodes).local_user_invitetable — stores invite links with optionalmax_uses,expires_at, auses_count, and astatus(Active,Revoked,Exhausted,Expired). Each invite is tied to the user who created it.invited_by_local_user_idcolumn onlocal_user— tracks who invited each user at registration time.Three new API endpoints:
POST /api/v4/account/invite— any authenticated user can create an invite link (with optional use cap and expiry).GET /api/v4/account/invite/list— list your own invitations;DELETE /api/v4/account/invite— revoke one of your own active invitations; returns an error if already revoked or exhausted.Per-user invite cap — new
max_invites_per_user_allowedconfig setting (optional u16) limits how many active invite links a non-admin user can hold at once. Admins bypass this limit.Scheduled expiry task — the hourly scheduled task now also sets
Activeinvites whoseexpires_athas passed toExpired.Database migration
migrations/2026-04-16-000000-0000_add_invitation_table/up.sql:RequireInvitationvalue to theregistration_mode_enum type.local_user_invite_status_enumtype andlocal_user_invitetable.invited_by_local_user_idcolumn tolocal_user.New error types
MissingInviteTokenRequireInvitationmode but noinvite_linksupplied at registrationInvalidInviteTokenActive/ it isExpired/ExhaustedTooManyInvitesmax_invites_per_user_allowedTesting
registration_modetoRequireInvitationvia the site settings.POST /api/v3/invite). Verify the response includes ainvite_linkURL of the form{scheme}{hostname}/signup?token={token}.uses_countincrements andinvited_by_local_user_idis set on the newlocal_userrow.max_uses: 1, use it once, and verify the status becomesExhaustedand a second registration attempt returnsInvalidInviteLink.expires_at, wait for (or manually trigger) the hourly task, and verify status becomesExpired.InvalidInviteToken.InviteAlreadyRevokedOrExhaustedis returned.max_invites_per_user_allowed: 2in config, create 2 invites as a regular user, and confirm the third attempt returnsTooManyInvites. Confirm an admin user is not rate-limited.Config
// How many active invite links a non-admin user may hold at once.
// Omit or set to null for no limit.