about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-11-27 16:07:59 +0100
committerGitHub <noreply@github.com>2017-11-27 16:07:59 +0100
commit740f8a95a905e949b6a74bc69dcaf638d2d46248 (patch)
tree8e09ff52b47c0cd303a9681014b34e68b5e28c51
parent0ea4478b68e60e442e5b254c2d2bc511e27fea83 (diff)
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
-rw-r--r--app/controllers/admin/invites_controller.rb33
-rw-r--r--app/controllers/admin/settings_controller.rb1
-rw-r--r--app/controllers/auth/registrations_controller.rb21
-rw-r--r--app/controllers/invites_controller.rb43
-rw-r--r--app/javascript/styles/mastodon/admin.scss16
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/invite.rb45
-rw-r--r--app/models/user.rb22
-rw-r--r--app/policies/invite_policy.rb25
-rw-r--r--app/views/admin/action_logs/_action_log.html.haml2
-rw-r--r--app/views/admin/invites/_invite.html.haml15
-rw-r--r--app/views/admin/invites/index.html.haml22
-rw-r--r--app/views/admin/settings/edit.html.haml5
-rw-r--r--app/views/auth/registrations/new.html.haml1
-rw-r--r--app/views/invites/_form.html.haml9
-rw-r--r--app/views/invites/_invite.html.haml11
-rw-r--r--app/views/invites/index.html.haml19
-rw-r--r--config/locales/en.yml24
-rw-r--r--config/locales/simple_form.en.yml2
-rw-r--r--config/navigation.rb3
-rw-r--r--config/routes.rb6
-rw-r--r--config/settings.yml1
-rw-r--r--db/migrate/20171125024930_create_invites.rb15
-rw-r--r--db/migrate/20171125031751_add_invite_id_to_users.rb5
-rw-r--r--db/schema.rb17
-rw-r--r--spec/fabricators/invite_fabricator.rb6
-rw-r--r--spec/models/invite_spec.rb30
-rw-r--r--spec/models/user_spec.rb43
28 files changed, 439 insertions, 5 deletions
diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb
new file mode 100644
index 000000000..f4207e3e2
--- /dev/null
+++ b/app/controllers/admin/invites_controller.rb
@@ -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
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index d9199b3d5..eed5fb6b5 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -16,6 +16,7 @@ module Admin
       show_staff_badge
       bootstrap_timeline_accounts
       thumbnail
+      min_invite_role
     ).freeze
 
     BOOLEAN_SETTINGS = %w(
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 223db96ff..da0b6512f 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -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
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
new file mode 100644
index 000000000..38d6c8d73
--- /dev/null
+++ b/app/controllers/invites_controller.rb
@@ -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
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index d4d62336f..7f078470e 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -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;
+  }
+}
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 6e9a2cd4b..c1d2cf420 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -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
diff --git a/app/models/invite.rb b/app/models/invite.rb
new file mode 100644
index 000000000..ceca04686
--- /dev/null
+++ b/app/models/invite.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index b9b228c00..578622fdf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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)
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
new file mode 100644
index 000000000..e5c68af19
--- /dev/null
+++ b/app/policies/invite_policy.rb
@@ -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
diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml
index 72816d731..ec90961cb 100644
--- a/app/views/admin/action_logs/_action_log.html.haml
+++ b/app/views/admin/action_logs/_action_log.html.haml
@@ -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
diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml
new file mode 100644
index 000000000..81edfd912
--- /dev/null
+++ b/app/views/admin/invites/_invite.html.haml
@@ -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?
diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml
new file mode 100644
index 000000000..52a748fe0
--- /dev/null
+++ b/app/views/admin/invites/index.html.haml
@@ -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
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index b07718315..c7c25f528 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -33,6 +33,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 }
 
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index f71675df0..2d4c0f5ac 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -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
diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml
new file mode 100644
index 000000000..99647f597
--- /dev/null
+++ b/app/views/invites/_form.html.haml
@@ -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
diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml
new file mode 100644
index 000000000..d794d72e4
--- /dev/null
+++ b/app/views/invites/_invite.html.haml
@@ -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?
diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml
new file mode 100644
index 000000000..f4c5047fa
--- /dev/null
+++ b/app/views/invites/index.html.haml
@@ -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
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 13b90cf0f..36b6981cb 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index faf41f316..ff1a40ccd 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -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
diff --git a/config/navigation.rb b/config/navigation.rb
index 26e6d386a..fdfd72b4c 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -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? }
diff --git a/config/routes.rb b/config/routes.rb
index d675fa846..59c3d4fdb 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/config/settings.yml b/config/settings.yml
index 5a0170fb4..f03a32e50 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -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
diff --git a/db/migrate/20171125024930_create_invites.rb b/db/migrate/20171125024930_create_invites.rb
new file mode 100644
index 000000000..bcf03bd72
--- /dev/null
+++ b/db/migrate/20171125024930_create_invites.rb
@@ -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
diff --git a/db/migrate/20171125031751_add_invite_id_to_users.rb b/db/migrate/20171125031751_add_invite_id_to_users.rb
new file mode 100644
index 000000000..16829f866
--- /dev/null
+++ b/db/migrate/20171125031751_add_invite_id_to_users.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 110a19845..7422bd127 100644
--- a/db/schema.rb
+++ b/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
diff --git a/spec/fabricators/invite_fabricator.rb b/spec/fabricators/invite_fabricator.rb
new file mode 100644
index 000000000..62b9b3904
--- /dev/null
+++ b/spec/fabricators/invite_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:invite) do
+  user
+  expires_at nil
+  max_uses   nil
+  uses       0
+end
diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb
new file mode 100644
index 000000000..0ba1dccb3
--- /dev/null
+++ b/spec/models/invite_spec.rb
@@ -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
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 77a12c26d..5ed7ed88b 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -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