Add consumable invites (#5814)
* Add consumable invites * Add UI for generating invite codes * Add tests * Display max uses and expiration in invites table, delete invite * Remove unused column and redundant validator - Default follows not used, probably bad idea - InviteCodeValidator is redundant because RegistrationsController checks invite code validity * Add admin setting to disable invites * Add admin UI for invites, configurable role for invite creation - Admin UI that lists everyone's invites, always available - Admin setting min_invite_role to control who can invite people - Non-admin invite UI only visible if users are allowed to * Do not remove invites from database, expire them instantly
This commit is contained in:
		
							
								
								
									
										33
									
								
								app/controllers/admin/invites_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/controllers/admin/invites_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| module Admin | ||||
|   class InvitesController < BaseController | ||||
|     def index | ||||
|       authorize :invite, :index? | ||||
|  | ||||
|       @invites = Invite.includes(user: :account).page(params[:page]) | ||||
|       @invite  = Invite.new | ||||
|     end | ||||
|  | ||||
|     def create | ||||
|       authorize :invite, :create? | ||||
|  | ||||
|       @invite      = Invite.new(resource_params) | ||||
|       @invite.user = current_user | ||||
|  | ||||
|       if @invite.save | ||||
|         redirect_to admin_invites_path | ||||
|       else | ||||
|         @invites = Invite.page(params[:page]) | ||||
|         render :index | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     def destroy | ||||
|       @invite = Invite.find(params[:id]) | ||||
|       authorize @invite, :destroy? | ||||
|       @invite.expire! | ||||
|       redirect_to admin_invites_path | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -16,6 +16,7 @@ module Admin | ||||
|       show_staff_badge | ||||
|       bootstrap_timeline_accounts | ||||
|       thumbnail | ||||
|       min_invite_role | ||||
|     ).freeze | ||||
|  | ||||
|     BOOLEAN_SETTINGS = %w( | ||||
|   | ||||
| @@ -16,13 +16,16 @@ class Auth::RegistrationsController < Devise::RegistrationsController | ||||
|  | ||||
|   def build_resource(hash = nil) | ||||
|     super(hash) | ||||
|     resource.locale = I18n.locale | ||||
|  | ||||
|     resource.locale      = I18n.locale | ||||
|     resource.invite_code = params[:invite_code] if resource.invite_code.blank? | ||||
|  | ||||
|     resource.build_account if resource.account.nil? | ||||
|   end | ||||
|  | ||||
|   def configure_sign_up_params | ||||
|     devise_parameter_sanitizer.permit(:sign_up) do |u| | ||||
|       u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation) | ||||
|       u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation, :invite_code) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -35,7 +38,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController | ||||
|   end | ||||
|  | ||||
|   def check_enabled_registrations | ||||
|     redirect_to root_path if single_user_mode? || !Setting.open_registrations | ||||
|     redirect_to root_path if single_user_mode? || !allowed_registrations? | ||||
|   end | ||||
|  | ||||
|   def allowed_registrations? | ||||
|     Setting.open_registrations || (invite_code.present? && Invite.find_by(code: invite_code)&.valid_for_use?) | ||||
|   end | ||||
|  | ||||
|   def invite_code | ||||
|     if params[:user] | ||||
|       params[:user][:invite_code] | ||||
|     else | ||||
|       params[:invite_code] | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   private | ||||
|   | ||||
							
								
								
									
										43
									
								
								app/controllers/invites_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/controllers/invites_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class InvitesController < ApplicationController | ||||
|   include Authorization | ||||
|  | ||||
|   layout 'admin' | ||||
|  | ||||
|   before_action :authenticate_user! | ||||
|  | ||||
|   def index | ||||
|     authorize :invite, :create? | ||||
|  | ||||
|     @invites = Invite.where(user: current_user) | ||||
|     @invite  = Invite.new(expires_in: 1.day.to_i) | ||||
|   end | ||||
|  | ||||
|   def create | ||||
|     authorize :invite, :create? | ||||
|  | ||||
|     @invite      = Invite.new(resource_params) | ||||
|     @invite.user = current_user | ||||
|  | ||||
|     if @invite.save | ||||
|       redirect_to invites_path | ||||
|     else | ||||
|       @invites = Invite.where(user: current_user) | ||||
|       render :index | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     @invite = Invite.where(user: current_user).find(params[:id]) | ||||
|     authorize @invite, :destroy? | ||||
|     @invite.expire! | ||||
|     redirect_to invites_path | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def resource_params | ||||
|     params.require(:invite).permit(:max_uses, :expires_in) | ||||
|   end | ||||
| end | ||||
| @@ -448,3 +448,19 @@ | ||||
|     color: $success-green; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .name-tag { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|  | ||||
|   .avatar { | ||||
|     display: block; | ||||
|     margin: 0; | ||||
|     margin-right: 5px; | ||||
|     border-radius: 50%; | ||||
|   } | ||||
|  | ||||
|   .username { | ||||
|     font-weight: 500; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -28,6 +28,8 @@ class Form::AdminSettings | ||||
|     :show_staff_badge=, | ||||
|     :bootstrap_timeline_accounts, | ||||
|     :bootstrap_timeline_accounts=, | ||||
|     :min_invite_role, | ||||
|     :min_invite_role=, | ||||
|     to: Setting | ||||
|   ) | ||||
| end | ||||
|   | ||||
							
								
								
									
										45
									
								
								app/models/invite.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								app/models/invite.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| # frozen_string_literal: true | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: invites | ||||
| # | ||||
| #  id         :integer          not null, primary key | ||||
| #  user_id    :integer | ||||
| #  code       :string           default(""), not null | ||||
| #  expires_at :datetime | ||||
| #  max_uses   :integer | ||||
| #  uses       :integer          default(0), not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
| # | ||||
|  | ||||
| class Invite < ApplicationRecord | ||||
|   belongs_to :user, required: true | ||||
|   has_many :users, inverse_of: :invite | ||||
|  | ||||
|   before_validation :set_code | ||||
|  | ||||
|   attr_reader :expires_in | ||||
|  | ||||
|   def expires_in=(interval) | ||||
|     self.expires_at = interval.to_i.seconds.from_now unless interval.blank? | ||||
|     @expires_in     = interval | ||||
|   end | ||||
|  | ||||
|   def valid_for_use? | ||||
|     (max_uses.nil? || uses < max_uses) && (expires_at.nil? || expires_at >= Time.now.utc) | ||||
|   end | ||||
|  | ||||
|   def expire! | ||||
|     touch(:expires_at) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_code | ||||
|     loop do | ||||
|       self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join | ||||
|       break if Invite.find_by(code: code).nil? | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -33,6 +33,7 @@ | ||||
| #  account_id                :integer          not null | ||||
| #  disabled                  :boolean          default(FALSE), not null | ||||
| #  moderator                 :boolean          default(FALSE), not null | ||||
| #  invite_id                 :integer | ||||
| # | ||||
|  | ||||
| class User < ApplicationRecord | ||||
| @@ -47,6 +48,7 @@ class User < ApplicationRecord | ||||
|          otp_number_of_backup_codes: 10 | ||||
|  | ||||
|   belongs_to :account, inverse_of: :user, required: true | ||||
|   belongs_to :invite, counter_cache: :uses | ||||
|   accepts_nested_attributes_for :account | ||||
|  | ||||
|   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner | ||||
| @@ -77,6 +79,8 @@ class User < ApplicationRecord | ||||
|            :reduce_motion, :system_font_ui, :noindex, :theme, | ||||
|            to: :settings, prefix: :setting, allow_nil: false | ||||
|  | ||||
|   attr_accessor :invite_code | ||||
|  | ||||
|   def confirmed? | ||||
|     confirmed_at.present? | ||||
|   end | ||||
| @@ -95,6 +99,19 @@ class User < ApplicationRecord | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def role?(role) | ||||
|     case role | ||||
|     when 'user' | ||||
|       true | ||||
|     when 'moderator' | ||||
|       staff? | ||||
|     when 'admin' | ||||
|       admin? | ||||
|     else | ||||
|       false | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def disable! | ||||
|     update!(disabled: true, | ||||
|             last_sign_in_at: current_sign_in_at, | ||||
| @@ -169,6 +186,11 @@ class User < ApplicationRecord | ||||
|     session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload | ||||
|   end | ||||
|  | ||||
|   def invite_code=(code) | ||||
|     self.invite  = Invite.find_by(code: code) unless code.blank? | ||||
|     @invite_code = code | ||||
|   end | ||||
|  | ||||
|   protected | ||||
|  | ||||
|   def send_devise_notification(notification, *args) | ||||
|   | ||||
							
								
								
									
										25
									
								
								app/policies/invite_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/policies/invite_policy.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class InvitePolicy < ApplicationPolicy | ||||
|   def index? | ||||
|     staff? | ||||
|   end | ||||
|  | ||||
|   def create? | ||||
|     min_required_role? | ||||
|   end | ||||
|  | ||||
|   def destroy? | ||||
|     owner? || staff? | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def owner? | ||||
|     record.user_id == current_user&.id | ||||
|   end | ||||
|  | ||||
|   def min_required_role? | ||||
|     current_user&.role?(Setting.min_invite_role) | ||||
|   end | ||||
| end | ||||
| @@ -1,7 +1,7 @@ | ||||
| %li.log-entry | ||||
|   .log-entry__header | ||||
|     .log-entry__avatar | ||||
|       = image_tag action_log.account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar' | ||||
|       = image_tag action_log.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar' | ||||
|     .log-entry__content | ||||
|       .log-entry__title | ||||
|         = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe | ||||
|   | ||||
							
								
								
									
										15
									
								
								app/views/admin/invites/_invite.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/views/admin/invites/_invite.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| %tr | ||||
|   %td | ||||
|     .name-tag | ||||
|       = image_tag invite.user.account.avatar.url(:original), alt: '', width: 16, height: 16, class: 'avatar' | ||||
|       %span.username= invite.user.account.username | ||||
|   %td | ||||
|     = invite.uses | ||||
|     = " / #{invite.max_uses}" unless invite.max_uses.nil? | ||||
|   %td | ||||
|     - if invite.expires_at.nil? | ||||
|       ∞ | ||||
|     - else | ||||
|       = l invite.expires_at | ||||
|   %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) | ||||
|   %td= table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete if policy(invite).destroy? | ||||
							
								
								
									
										22
									
								
								app/views/admin/invites/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/views/admin/invites/index.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| - content_for :page_title do | ||||
|   = t('admin.invites.title') | ||||
|  | ||||
| - if policy(:invite).create? | ||||
|   %p= t('invites.prompt') | ||||
|  | ||||
|   = render 'invites/form' | ||||
|  | ||||
|   %hr/ | ||||
|  | ||||
| %table.table | ||||
|   %thead | ||||
|     %tr | ||||
|       %th | ||||
|       %th= t('invites.table.uses') | ||||
|       %th= t('invites.table.expires_at') | ||||
|       %th | ||||
|       %th | ||||
|   %tbody | ||||
|     = render @invites | ||||
|  | ||||
| = paginate @invites | ||||
| @@ -32,6 +32,11 @@ | ||||
|  | ||||
|   %hr/ | ||||
|  | ||||
|   .fields-group | ||||
|     = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, as: :radio_buttons, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
|  | ||||
|   %hr/ | ||||
|  | ||||
|   .fields-group | ||||
|     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } | ||||
|     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } | ||||
|   | ||||
| @@ -16,6 +16,7 @@ | ||||
|   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } | ||||
|   = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } | ||||
|   = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } | ||||
|   = f.input :invite_code, as: :hidden | ||||
|  | ||||
|   .actions | ||||
|     = f.button :button, t('auth.register'), type: :submit | ||||
|   | ||||
							
								
								
									
										9
									
								
								app/views/invites/_form.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/views/invites/_form.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| = simple_form_for(@invite) do |f| | ||||
|   = render 'shared/error_messages', object: @invite | ||||
|  | ||||
|   .fields-group | ||||
|     = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') | ||||
|     = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') | ||||
|  | ||||
|   .actions | ||||
|     = f.button :button, t('invites.generate'), type: :submit | ||||
							
								
								
									
										11
									
								
								app/views/invites/_invite.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/views/invites/_invite.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| %tr | ||||
|   %td | ||||
|     = invite.uses | ||||
|     = " / #{invite.max_uses}" unless invite.max_uses.nil? | ||||
|   %td | ||||
|     - if invite.expires_at.nil? | ||||
|       ∞ | ||||
|     - else | ||||
|       = l invite.expires_at | ||||
|   %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code) | ||||
|   %td= table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete if policy(invite).destroy? | ||||
							
								
								
									
										19
									
								
								app/views/invites/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/views/invites/index.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| - content_for :page_title do | ||||
|   = t('invites.title') | ||||
|  | ||||
| - if policy(:invite).create? | ||||
|   %p= t('invites.prompt') | ||||
|  | ||||
|   = render 'form' | ||||
|  | ||||
|   %hr/ | ||||
|  | ||||
| %table.table | ||||
|   %thead | ||||
|     %tr | ||||
|       %th= t('invites.table.uses') | ||||
|       %th= t('invites.table.expires_at') | ||||
|       %th | ||||
|       %th | ||||
|   %tbody | ||||
|     = render @invites | ||||
| @@ -231,6 +231,8 @@ en: | ||||
|       reset: Reset | ||||
|       search: Search | ||||
|       title: Known instances | ||||
|     invites: | ||||
|       title: Invites | ||||
|     reports: | ||||
|       action_taken_by: Action taken by | ||||
|       are_you_sure: Are you sure? | ||||
| @@ -269,6 +271,9 @@ en: | ||||
|         deletion: | ||||
|           desc_html: Allow anyone to delete their account | ||||
|           title: Open account deletion | ||||
|         min_invite_role: | ||||
|           disabled: No one | ||||
|           title: Allow invitations by | ||||
|         open: | ||||
|           desc_html: Allow anyone to create an account | ||||
|           title: Open registration | ||||
| @@ -424,6 +429,25 @@ en: | ||||
|       muting: Muting list | ||||
|     upload: Upload | ||||
|   in_memoriam_html: In Memoriam. | ||||
|   invites: | ||||
|     delete: Delete | ||||
|     expires_in: | ||||
|       '1800': 30 minutes | ||||
|       '21600': 6 hours | ||||
|       '3600': 1 hour | ||||
|       '43200': 12 hours | ||||
|       '86400': 1 day | ||||
|     expires_in_prompt: Never | ||||
|     generate: Generate | ||||
|     max_uses: | ||||
|       one: 1 use | ||||
|       other: "%{count} uses" | ||||
|     max_uses_prompt: No limit | ||||
|     prompt: Generate and share links with others to grant access to this instance | ||||
|     table: | ||||
|       expires_at: Expires | ||||
|       uses: Uses | ||||
|     title: Invite people | ||||
|   landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse." | ||||
|   landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>. | ||||
|   media_attachments: | ||||
|   | ||||
| @@ -30,10 +30,12 @@ en: | ||||
|         data: Data | ||||
|         display_name: Display name | ||||
|         email: E-mail address | ||||
|         expires_in: Expire after | ||||
|         filtered_languages: Filtered languages | ||||
|         header: Header | ||||
|         locale: Language | ||||
|         locked: Lock account | ||||
|         max_uses: Max number of uses | ||||
|         new_password: New password | ||||
|         note: Bio | ||||
|         otp_attempt: Two-factor code | ||||
|   | ||||
| @@ -16,6 +16,8 @@ SimpleNavigation::Configuration.run do |navigation| | ||||
|       settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url | ||||
|     end | ||||
|  | ||||
|     primary.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' } | ||||
|  | ||||
|     primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development| | ||||
|       development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications} | ||||
|     end | ||||
| @@ -24,6 +26,7 @@ SimpleNavigation::Configuration.run do |navigation| | ||||
|       admin.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url | ||||
|       admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} | ||||
|       admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} | ||||
|       admin.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path | ||||
|       admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances}, if: -> { current_user.admin? } | ||||
|       admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks}, if: -> { current_user.admin? } | ||||
|       admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } | ||||
|   | ||||
| @@ -22,6 +22,10 @@ Rails.application.routes.draw do | ||||
|   get 'manifest', to: 'manifests#show', defaults: { format: 'json' } | ||||
|   get 'intent', to: 'intents#show' | ||||
|  | ||||
|   devise_scope :user do | ||||
|     get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite | ||||
|   end | ||||
|  | ||||
|   devise_for :users, path: 'auth', controllers: { | ||||
|     sessions:           'auth/sessions', | ||||
|     registrations:      'auth/registrations', | ||||
| @@ -99,6 +103,7 @@ Rails.application.routes.draw do | ||||
|   resources :media,  only: [:show] | ||||
|   resources :tags,   only: [:show] | ||||
|   resources :emojis, only: [:show] | ||||
|   resources :invites, only: [:index, :create, :destroy] | ||||
|  | ||||
|   get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy | ||||
|  | ||||
| @@ -112,6 +117,7 @@ Rails.application.routes.draw do | ||||
|     resources :email_domain_blocks, only: [:index, :new, :create, :destroy] | ||||
|     resources :action_logs, only: [:index] | ||||
|     resource :settings, only: [:edit, :update] | ||||
|     resources :invites, only: [:index, :create, :destroy] | ||||
|  | ||||
|     resources :instances, only: [:index] do | ||||
|       collection do | ||||
|   | ||||
| @@ -16,6 +16,7 @@ defaults: &defaults | ||||
|   open_registrations: true | ||||
|   closed_registrations_message: '' | ||||
|   open_deletion: true | ||||
|   min_invite_role: 'admin' | ||||
|   timeline_preview: true | ||||
|   show_staff_badge: true | ||||
|   default_sensitive: false | ||||
|   | ||||
							
								
								
									
										15
									
								
								db/migrate/20171125024930_create_invites.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								db/migrate/20171125024930_create_invites.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| class CreateInvites < ActiveRecord::Migration[5.1] | ||||
|   def change | ||||
|     create_table :invites do |t| | ||||
|       t.belongs_to :user, foreign_key: { on_delete: :cascade } | ||||
|       t.string :code, null: false, default: '' | ||||
|       t.datetime :expires_at, null: true, default: nil | ||||
|       t.integer :max_uses, null: true, default: nil | ||||
|       t.integer :uses, null: false, default: 0 | ||||
|  | ||||
|       t.timestamps | ||||
|     end | ||||
|  | ||||
|     add_index :invites, :code, unique: true | ||||
|   end | ||||
| end | ||||
							
								
								
									
										5
									
								
								db/migrate/20171125031751_add_invite_id_to_users.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/migrate/20171125031751_add_invite_id_to_users.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| class AddInviteIdToUsers < ActiveRecord::Migration[5.1] | ||||
|   def change | ||||
|     add_reference :users, :invite, null: true, default: nil, foreign_key: { on_delete: :nullify }, index: false | ||||
|   end | ||||
| end | ||||
							
								
								
									
										17
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								db/schema.rb
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 20171122120436) do | ||||
| ActiveRecord::Schema.define(version: 20171125031751) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
| @@ -183,6 +183,18 @@ ActiveRecord::Schema.define(version: 20171122120436) do | ||||
|     t.bigint "account_id", null: false | ||||
|   end | ||||
|  | ||||
|   create_table "invites", force: :cascade do |t| | ||||
|     t.bigint "user_id" | ||||
|     t.string "code", default: "", null: false | ||||
|     t.datetime "expires_at" | ||||
|     t.integer "max_uses" | ||||
|     t.integer "uses", default: 0, null: false | ||||
|     t.datetime "created_at", null: false | ||||
|     t.datetime "updated_at", null: false | ||||
|     t.index ["code"], name: "index_invites_on_code", unique: true | ||||
|     t.index ["user_id"], name: "index_invites_on_user_id" | ||||
|   end | ||||
|  | ||||
|   create_table "list_accounts", force: :cascade do |t| | ||||
|     t.bigint "list_id", null: false | ||||
|     t.bigint "account_id", null: false | ||||
| @@ -473,6 +485,7 @@ ActiveRecord::Schema.define(version: 20171122120436) do | ||||
|     t.bigint "account_id", null: false | ||||
|     t.boolean "disabled", default: false, null: false | ||||
|     t.boolean "moderator", default: false, null: false | ||||
|     t.bigint "invite_id" | ||||
|     t.index ["account_id"], name: "index_users_on_account_id" | ||||
|     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true | ||||
|     t.index ["email"], name: "index_users_on_email", unique: true | ||||
| @@ -513,6 +526,7 @@ ActiveRecord::Schema.define(version: 20171122120436) do | ||||
|   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade | ||||
|   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade | ||||
|   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade | ||||
|   add_foreign_key "invites", "users", on_delete: :cascade | ||||
|   add_foreign_key "list_accounts", "accounts", on_delete: :cascade | ||||
|   add_foreign_key "list_accounts", "follows", on_delete: :cascade | ||||
|   add_foreign_key "list_accounts", "lists", on_delete: :cascade | ||||
| @@ -546,5 +560,6 @@ ActiveRecord::Schema.define(version: 20171122120436) do | ||||
|   add_foreign_key "stream_entries", "accounts", name: "fk_5659b17554", on_delete: :cascade | ||||
|   add_foreign_key "subscriptions", "accounts", name: "fk_9847d1cbb5", on_delete: :cascade | ||||
|   add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade | ||||
|   add_foreign_key "users", "invites", on_delete: :nullify | ||||
|   add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade | ||||
| end | ||||
|   | ||||
							
								
								
									
										6
									
								
								spec/fabricators/invite_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								spec/fabricators/invite_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| Fabricator(:invite) do | ||||
|   user | ||||
|   expires_at nil | ||||
|   max_uses   nil | ||||
|   uses       0 | ||||
| end | ||||
							
								
								
									
										30
									
								
								spec/models/invite_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								spec/models/invite_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe Invite, type: :model do | ||||
|   describe '#valid_for_use?' do | ||||
|     it 'returns true when there are no limitations' do | ||||
|       invite = Invite.new(max_uses: nil, expires_at: nil) | ||||
|       expect(invite.valid_for_use?).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns true when not expired' do | ||||
|       invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now) | ||||
|       expect(invite.valid_for_use?).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns false when expired' do | ||||
|       invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago) | ||||
|       expect(invite.valid_for_use?).to be false | ||||
|     end | ||||
|  | ||||
|     it 'returns true when uses still available' do | ||||
|       invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil) | ||||
|       expect(invite.valid_for_use?).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns false when maximum uses reached' do | ||||
|       invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil) | ||||
|       expect(invite.valid_for_use?).to be false | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -273,4 +273,47 @@ RSpec.describe User, type: :model do | ||||
|       expect(user.token_for_app(app)).to be_nil | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#role' do | ||||
|     it 'returns admin for admin' do | ||||
|       user = User.new(admin: true) | ||||
|       expect(user.role).to eq 'admin' | ||||
|     end | ||||
|  | ||||
|     it 'returns moderator for moderator' do | ||||
|       user = User.new(moderator: true) | ||||
|       expect(user.role).to eq 'moderator' | ||||
|     end | ||||
|  | ||||
|     it 'returns user otherwise' do | ||||
|       user = User.new | ||||
|       expect(user.role).to eq 'user' | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe '#role?' do | ||||
|     it 'returns false when invalid role requested' do | ||||
|       user = User.new(admin: true) | ||||
|       expect(user.role?('disabled')).to be false | ||||
|     end | ||||
|  | ||||
|     it 'returns true when exact role match' do | ||||
|       user  = User.new | ||||
|       mod   = User.new(moderator: true) | ||||
|       admin = User.new(admin: true) | ||||
|  | ||||
|       expect(user.role?('user')).to be true | ||||
|       expect(mod.role?('moderator')).to be true | ||||
|       expect(admin.role?('admin')).to be true | ||||
|     end | ||||
|  | ||||
|     it 'returns true when role higher than needed' do | ||||
|       mod   = User.new(moderator: true) | ||||
|       admin = User.new(admin: true) | ||||
|  | ||||
|       expect(mod.role?('user')).to be true | ||||
|       expect(admin.role?('user')).to be true | ||||
|       expect(admin.role?('moderator')).to be true | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
		Reference in New Issue
	
	Block a user