diff --git a/.rubocop.yml b/.rubocop.yml index e3b44c70..2b20bbb3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,7 @@ Metrics/AbcSize: - 'app/controllers/posts_controller.rb' - 'app/controllers/invitadxs_controller.rb' - 'app/controllers/i18n_controller.rb' + - 'app/controllers/usuaries_controller.rb' Metrics/MethodLength: Exclude: diff --git a/Gemfile b/Gemfile index 4026dcd2..10313805 100644 --- a/Gemfile +++ b/Gemfile @@ -41,6 +41,7 @@ gem 'carrierwave-i18n' gem 'commonmarker' gem 'devise' gem 'devise-i18n' +gem 'devise_invitable' gem 'email_address' gem 'exception_notification' gem 'font-awesome-rails' diff --git a/Gemfile.lock b/Gemfile.lock index dc95aedf..67cc2bcd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,9 @@ GEM warden (~> 1.2.3) devise-i18n (1.8.0) devise (>= 4.6) + devise_invitable (2.0.1) + actionmailer (>= 5.0) + devise (>= 4.6) dotenv (2.7.2) dotenv-rails (2.7.2) dotenv (= 2.7.2) @@ -363,6 +366,7 @@ DEPENDENCIES commonmarker devise devise-i18n + devise_invitable dotenv-rails ed25519 email_address diff --git a/app/controllers/usuaries_controller.rb b/app/controllers/usuaries_controller.rb index 1aef1b6b..85208992 100644 --- a/app/controllers/usuaries_controller.rb +++ b/app/controllers/usuaries_controller.rb @@ -51,4 +51,59 @@ class UsuariesController < ApplicationController redirect_to site_usuaries_path end + + # Poder invitar + def invite + @site = find_site + site_usuarie = SiteUsuarie.new(@site, current_usuarie) + authorize site_usuarie + + @policy = policy(site_usuarie) + end + + # Envía las invitaciones + def send_invitations + @site = find_site + authorize SiteUsuarie.new(@site, current_usuarie) + + # Enviar la invitación si es necesario y agregar al sitio + # + # TODO: Pedir consentimiento para agregar a un sitio! + invitaciones.each do |invitacion| + # Si la cuenta no existe, envía una invitación por correo + usuarie = Usuarie.invite! email: invitacion.address + + # No invitar al sitio si ya estaba en la lista! + unless @site.invitade?(usuarie) || @site.usuarie?(usuarie) + @site.send(invited_as) << usuarie + end + end + + redirect_to site_usuaries_path(@site) + end + + private + + # Traer todas las invitaciones que al menos tengan usuarie y dominio + def invitaciones + # XXX: Podríamos usar EmailAddress pero hace chequeos más lentos + i = params[:invitaciones].split("\n").map do |m| + Mail::Address.new m + rescue Mail::Field::IncompleteParseError + nil + end.compact + + i.select do |m| + m.local && m.domain + end + end + + # El tipo de invitación que tenemos que enviar, si alguien mandó + # cualquier cosa, usamos el privilegio menor. + def invited_as + invited_as = params[:invited_as].try(:to_sym) + invited_as = :invitades unless %i[usuaries invitades].include?(invited_as) + + invited_as + end end diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb index b0f2e3b6..8fea94a2 100644 --- a/app/models/usuarie.rb +++ b/app/models/usuarie.rb @@ -2,7 +2,7 @@ # Usuarie de la plataforma class Usuarie < ApplicationRecord - devise :database_authenticatable, + devise :invitable, :database_authenticatable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :registerable diff --git a/app/policies/site_usuarie_policy.rb b/app/policies/site_usuarie_policy.rb index d3734803..ded45407 100644 --- a/app/policies/site_usuarie_policy.rb +++ b/app/policies/site_usuarie_policy.rb @@ -27,6 +27,14 @@ class SiteUsuariePolicy usuarie? end + def invite? + usuarie? + end + + def send_invitations? + usuarie? + end + private def usuarie? diff --git a/app/views/devise/invitations/edit.haml b/app/views/devise/invitations/edit.haml new file mode 100644 index 00000000..f2dd5b8e --- /dev/null +++ b/app/views/devise/invitations/edit.haml @@ -0,0 +1,16 @@ +.row.align-items-center.justify-content-center.full-height + .col-md-6.align-self-center + %h2= t 'devise.invitations.edit.header' + = form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :put }) do |f| + = render 'devise/shared/error_messages', resource: resource + = f.hidden_field :invitation_token, readonly: true + - if f.object.class.require_password_on_accepting + .form-group + = f.label :password + = f.password_field :password, class: 'form-control' + .form-group + = f.label :password_confirmation + = f.password_field :password_confirmation, class: 'form-control' + .actions + = f.submit t('devise.invitations.edit.submit_button'), + class: 'btn btn-lg btn-primary btn-block' diff --git a/app/views/devise/invitations/new.haml b/app/views/devise/invitations/new.haml new file mode 100644 index 00000000..e5116dc4 --- /dev/null +++ b/app/views/devise/invitations/new.haml @@ -0,0 +1,12 @@ +.row.align-items-center.justify-content-center.full-height + .col-md-6.align-self-center + %h2= t 'devise.invitations.new.header' + = form_for(resource, as: resource_name, url: invitation_path(resource_name), html: { method: :post }) do |f| + = render 'devise/shared/error_messages', resource: resource + - resource.class.invite_key_fields.each do |field| + .form-group + = f.label field + = f.text_field field, class: 'form-control' + .actions + = f.submit t('devise.invitations.new.submit_button'), + class: 'btn btn-lg btn-primary btn-block' diff --git a/app/views/devise/mailer/invitation_instructions.haml b/app/views/devise/mailer/invitation_instructions.haml new file mode 100644 index 00000000..f2f42219 --- /dev/null +++ b/app/views/devise/mailer/invitation_instructions.haml @@ -0,0 +1,6 @@ +%p= t("devise.mailer.invitation_instructions.hello", email: @resource.email) +%p= t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) +%p= link_to t("devise.mailer.invitation_instructions.accept"), accept_invitation_url(@resource, invitation_token: @token) +- if @resource.invitation_due_at + %p= t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) +%p= t("devise.mailer.invitation_instructions.ignore") diff --git a/app/views/devise/mailer/invitation_instructions.text.haml b/app/views/devise/mailer/invitation_instructions.text.haml new file mode 100644 index 00000000..a9d23d3f --- /dev/null +++ b/app/views/devise/mailer/invitation_instructions.text.haml @@ -0,0 +1,6 @@ += t("devise.mailer.invitation_instructions.hello", email: @resource.email) += t("devise.mailer.invitation_instructions.someone_invited_you", url: root_url) += accept_invitation_url(@resource, invitation_token: @token) +- if @resource.invitation_due_at + = t("devise.mailer.invitation_instructions.accept_until", due_date: l(@resource.invitation_due_at, format: :'devise.mailer.invitation_instructions.accept_until_format')) += t("devise.mailer.invitation_instructions.ignore") diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb deleted file mode 100644 index 37f0bddb..00000000 --- a/app/views/layouts/mailer.text.erb +++ /dev/null @@ -1 +0,0 @@ -<%= yield %> diff --git a/app/views/usuaries/index.haml b/app/views/usuaries/index.haml index 733e4342..f8408da6 100644 --- a/app/views/usuaries/index.haml +++ b/app/views/usuaries/index.haml @@ -14,7 +14,15 @@ .col -# Una tabla de usuaries y otra de invitades, con acciones - %i[usuaries invitades].each do |u| - %h2= t(".#{u.to_s}") + %h2 + = t(".#{u.to_s}") + .btn-group{role: 'group', 'aria-label': t('.actions')} + - if @policy.invite? + = link_to t('.invite.text'), + site_usuaries_invite_path(@site, invite_as: u.to_s), + class: 'btn btn-success', + data: { toggle: 'tooltip' }, + title: t('.help.invite', invite_as: u.to_s) %p= t(".help.#{u.to_s}") %table.table.table-striped.table-condensed %tbody @@ -23,7 +31,7 @@ -# TODO: avatares %td= cuenta.email %td - .btn-group{role: 'group', 'aria-label': t('.actions')} + .btn-group{role: 'group', 'aria-label': t('.individual_actions')} - if @policy.demote? && @site.usuarie?(cuenta) = link_to t('.demote.text'), site_usuarie_demote_path(@site, cuenta), diff --git a/app/views/usuaries/invite.haml b/app/views/usuaries/invite.haml new file mode 100644 index 00000000..49d83205 --- /dev/null +++ b/app/views/usuaries/invite.haml @@ -0,0 +1,26 @@ +- invite_as = t("usuaries.invite_as.#{params[:invite_as]}") +.row + .col + = render 'layouts/breadcrumb', + crumbs: [ link_to(t('sites.index'), sites_path), + @site.name, + link_to(t('posts.index'), + site_usuaries_path(@site)), + t('.title', invite_as: invite_as) ] + = render 'layouts/help', help: t('help.breadcrumbs') +.row + .col + %h1= t('.title', invite_as: invite_as) + +.row + .col + = form_with url: site_usuaries_invite_path(@site) do |f| + = f.hidden_field :invite_as, value: params[:invite_as] + .form-group + = f.label :invitaciones do + = t('.invitaciones') + %small.text-muted.form-text= t('.help.invitaciones', + invite_as: invite_as) + = f.text_area :invitaciones, class: 'form-control' + .form-group + = f.submit t('.submit'), class: 'btn btn-success' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 256b37e1..a963058b 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -136,6 +136,58 @@ Devise.setup do |config| # Send a notification email when the user's password is changed. config.send_password_change_notification = true + # ==> Configuration for :invitable + # The period the generated invitation token is valid. After this + # period, the invited resource won't be able to accept the invitation. + # When invite_for is 0 (the default), the invitation won't expire. + config.invite_for = 2.weeks + + # Number of invitations users can send. + # - If invitation_limit is nil, there is no limit for invitations, + # users can send unlimited invitations, invitation_limit column is not + # used. + # - If invitation_limit is 0, users can't send invitations by default. + # - If invitation_limit n > 0, users can send n invitations. + # You can change invitation_limit column for some users so they can + # send more or less invitations, even with global invitation_limit = 0 + # Default: nil + config.invitation_limit = 0 + + # The key to be used to check existing users when sending an + # invitation and the regexp used to test it when validate_on_invite is + # not set. + # config.invite_key = { email: /\A[^@]+@[^@]+\z/ } + # config.invite_key = { email: /\A[^@]+@[^@]+\z/, username: nil } + + # Ensure that invited record is valid. + # The invitation won't be sent if this check fails. + # Default: false + # config.validate_on_invite = true + + # Resend invitation if user with invited status is invited again + # Default: true + # config.resend_invitation = false + + # The class name of the inviting model. If this is nil, + # the #invited_by association is declared to be polymorphic. + # Default: nil + # config.invited_by_class_name = 'User' + + # The foreign key to the inviting model (if invited_by_class_name is + # set) + # Default: :invited_by_id + # config.invited_by_foreign_key = :invited_by_id + + # The column name used for counter_cache column. If this is nil, + # the #invited_by association is declared without counter_cache. + # Default: nil + # config.invited_by_counter_cache = :invitations_count + + # Auto-login after the user accepts the invite. If this is false, + # the user will need to manually log in after accepting the invite. + # Default: true + # config.allow_insecure_sign_in_after_accept = false + # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without # confirming their account. For instance, if set to 2.days, the user diff --git a/config/locales/devise_invitable.en.yml b/config/locales/devise_invitable.en.yml new file mode 100644 index 00000000..f6bfee40 --- /dev/null +++ b/config/locales/devise_invitable.en.yml @@ -0,0 +1,31 @@ +en: + devise: + failure: + invited: "You have a pending invitation, accept it to finish creating your account." + invitations: + send_instructions: "An invitation email has been sent to %{email}." + invitation_token_invalid: "The invitation token provided is not valid!" + updated: "Your password was set successfully. You are now signed in." + updated_not_active: "Your password was set successfully." + no_invitations_remaining: "No invitations remaining" + invitation_removed: "Your invitation was removed." + new: + header: "Send invitation" + submit_button: "Send an invitation" + edit: + header: "Set your password" + submit_button: "Set my password" + mailer: + invitation_instructions: + subject: "Invitation instructions" + hello: "Hello %{email}" + someone_invited_you: "Someone has invited you to %{url}, you can accept it through the link below." + accept: "Accept invitation" + accept_until: "This invitation will be due in %{due_date}." + ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password." + time: + formats: + devise: + mailer: + invitation_instructions: + accept_until_format: "%B %d, %Y %I:%M %p" diff --git a/config/locales/devise_invitable.es.yml b/config/locales/devise_invitable.es.yml new file mode 100644 index 00000000..f12bf2a2 --- /dev/null +++ b/config/locales/devise_invitable.es.yml @@ -0,0 +1,31 @@ +es: + devise: + failure: + invited: "Tenés una invitación pendiente, aceptala para terminar de crear tu cuenta." + invitations: + send_instructions: "Hemos enviado una invitación a %{email}." + invitation_token_invalid: "¡El código de invitación no es válido!" + updated: "Configuraste tu contraseña correctamente, a partir de ahora podés ingresar." + updated_not_active: "Configuraste tu contraseña correctamente." + no_invitations_remaining: "No te quedan invitaciones" + invitation_removed: "La invitación ha sido eliminada." + new: + header: "Invitar a alguien" + submit_button: "Enviar la invitación" + edit: + header: "Configura tu contraseña" + submit_button: "Configurar mi contraseña" + mailer: + invitation_instructions: + subject: "Invitación" + hello: "Hola %{email}" + someone_invited_you: "Alguien te ha invitado a colaborar en %{url}, podés aceptar la invitación con el enlace a continuación." + accept: "Aceptar la invitación" + accept_until: "La invitación vencerá en %{due_date}." + ignore: "Si no querés aceptar la invitación, por favor ignora este correo. Tu cuenta no será creada hasta que aceptes la invitación y configures una contraseña." + time: + formats: + devise: + mailer: + invitation_instructions: + accept_until_format: "%d/%m/%Y a las %H:%M" diff --git a/config/locales/en.yml b/config/locales/en.yml index ae0fdff7..3675665b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -259,6 +259,9 @@ en: destroy: Delete confirm_destroy: Are you sure? usuaries: + invite_as: + usuaries: users + invitades: guests index: help: self: Self-manage who has access to this site @@ -267,6 +270,7 @@ en: invitades: 'Guests can only create new posts and edit those authored by them. Any change needs review and approval by a user.' demote: Removes privileges for this user promote: Gives privileges to this guest + invite: 'Invites %{invite_as} to this site' title: Users and Guests usuaries: Users invitades: Guests @@ -279,3 +283,11 @@ en: promote: text: 'Convert to user' confirm: 'Convert to user? They will gain full access to self-manage this site.' + invite: + text: 'Invite' + invite: + title: 'Invite %{invite_as}' + help: + invitaciones: 'You can invite new %{invite_as} by entering an email address per line. They will receive an email with your invitation and decide if they want to accept or not.' + invitaciones: Send invitations to these addresses + submit: Send invitations diff --git a/config/locales/es.yml b/config/locales/es.yml index f34996a8..8900479a 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -266,6 +266,9 @@ es: destroy: Borrar confirm_destroy: ¿Estás segurx? usuaries: + invite_as: + usuaries: usuaries + invitades: invitades index: help: self: Gestionar quiénes tienen acceso a este sitio @@ -274,6 +277,7 @@ es: invitades: 'Les invitades sólo pueden crear artículos nuevos y modificar los que cargaron. Todos los cambios que hagan necesitan la revisión y aprobación de une usuarie.' demote: Quita privilegios a este usuarie promote: Otorga privilegios a este invitade + invite: 'Invita %{invite_as} a colaborar en este sitio' title: Usuaries e invitades usuaries: Usuaries invitades: Invitades @@ -286,3 +290,11 @@ es: promote: text: 'Convertir en usuarie' confirm: '¿Convertir en usuarie? Ganará acceso a la gestión completa del sitio.' + invite: + text: 'Invitar' + invite: + title: 'Invitar %{invite_as}' + help: + invitaciones: 'Invita nueves %{invite_as} ingresando una dirección de correo electrónico por línea. Recibirán un correo con tu invitación y podrán aceptarla o no.' + invitaciones: Invitar a estas direcciones + submit: Enviar invitaciones diff --git a/config/routes.rb b/config/routes.rb index 3f225453..d7d5cd5e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -17,6 +17,8 @@ Rails.application.routes.draw do get 'public/:type/:basename', to: 'sites#send_public_file' # Gestionar usuaries + get 'usuaries/invite', to: 'usuaries#invite' + post 'usuaries/invite', to: 'usuaries#send_invitations' resources :usuaries do patch 'demote', to: 'usuaries#demote' patch 'promote', to: 'usuaries#promote' diff --git a/db/migrate/20190705215536_devise_invitable_add_to_usuaries.rb b/db/migrate/20190705215536_devise_invitable_add_to_usuaries.rb new file mode 100644 index 00000000..466f94db --- /dev/null +++ b/db/migrate/20190705215536_devise_invitable_add_to_usuaries.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Agregar gestión de invitaciones a usuaries +class DeviseInvitableAddToUsuaries < ActiveRecord::Migration[5.2] + def up + change_table :usuaries do |t| + t.string :invitation_token + t.datetime :invitation_created_at + t.datetime :invitation_sent_at + t.datetime :invitation_accepted_at + t.integer :invitation_limit + t.references :invited_by, polymorphic: true + t.integer :invitations_count, default: 0 + t.index :invitations_count + t.index :invitation_token, unique: true # for invitable + t.index :invited_by_id + end + end + + def down + change_table :usuaries do |t| + t.remove_references :invited_by, polymorphic: true + t.remove :invitations_count, :invitation_limit, :invitation_sent_at, :invitation_accepted_at, :invitation_token, :invitation_created_at + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c1b435fe..4a6bfe39 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_705_195_758) do +ActiveRecord::Schema.define(version: 20_190_705_215_536) do create_table 'invitades_sites', id: false, force: :cascade do |t| t.integer 'site_id' t.integer 'usuarie_id' @@ -48,8 +48,20 @@ ActiveRecord::Schema.define(version: 20_190_705_195_758) do t.string 'unlock_token' t.datetime 'locked_at' t.boolean 'acepta_politicas_de_privacidad', default: false + t.string 'invitation_token' + t.datetime 'invitation_created_at' + t.datetime 'invitation_sent_at' + t.datetime 'invitation_accepted_at' + t.integer 'invitation_limit' + t.string 'invited_by_type' + t.integer 'invited_by_id' + t.integer 'invitations_count', default: 0 t.index ['confirmation_token'], name: 'index_usuaries_on_confirmation_token', unique: true t.index ['email'], name: 'index_usuaries_on_email', unique: true + t.index ['invitation_token'], name: 'index_usuaries_on_invitation_token', unique: true + t.index ['invitations_count'], name: 'index_usuaries_on_invitations_count' + t.index ['invited_by_id'], name: 'index_usuaries_on_invited_by_id' + t.index %w[invited_by_type invited_by_id], name: 'index_usuaries_on_invited_by_type_and_invited_by_id' t.index ['reset_password_token'], name: 'index_usuaries_on_reset_password_token', unique: true t.index ['unlock_token'], name: 'index_usuaries_on_unlock_token', unique: true end diff --git a/doc/autenticacion.md b/doc/autenticacion.md index c5b38133..93335fad 100644 --- a/doc/autenticacion.md +++ b/doc/autenticacion.md @@ -43,3 +43,26 @@ está en su lista de invitade. Lo mismo para usuaries. Les usuaries pueden bloquear invitades y a otres usuaries, y sumar usuaries e invitades a su sitio (via correo de invitación). + +## Invitaciones a sitios + +### Enviar invitación + +Desde la gestión del sitio se puede invitar a nueves usuaries e +invitades. Se les envía un correo (y cuando tengamos sistema de +notificaciones, una notificación) donde pueden confirmar su +participación. + +Si no tienen cuenta, tienen que registrarse completando los datos en el +momento, sino se pueden loguear normalmente. + +Si ya están logueades, se acepta la invitación inmediatamente. + +### Invitades desde la web + +Al publicar la URL de invitación, les invitades se puedan registrar +por su cuenta. Solo deben completar sus datos (correo y contraseña) y +reciben un correo de confirmación. + +En la configuración del sitio hay que distinguir entre invitades por +invitación o por registro automático.