diff --git a/app/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller.rb b/app/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller.rb index 44a4231..b465242 100644 --- a/app/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller.rb +++ b/app/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller.rb @@ -39,7 +39,7 @@ def destroy end def resend - @accounts_invitation.send_invite + @accounts_invitation.resend_invite render json: {status: 200}, status: :ok end diff --git a/app/controllers/api/v1/shopkeeper/accounts_invitations_controller.rb b/app/controllers/api/v1/shopkeeper/accounts_invitations_controller.rb index 1826764..c8b15d8 100644 --- a/app/controllers/api/v1/shopkeeper/accounts_invitations_controller.rb +++ b/app/controllers/api/v1/shopkeeper/accounts_invitations_controller.rb @@ -3,12 +3,22 @@ class Api::V1::Shopkeeper::AccountsInvitationsController < Api::V1::Shopkeeper:: skip_after_action :verify_authorized def show + if @accounts_invitation.expired? + render json: {code: 410, error_message: I18n.t("api.shopkeeper.accounts_invitations.expired")}, status: :gone + return + end + options = {} options[:include] = [:account, :invited_by] render json: AccountsInvitationSerializer.new(@accounts_invitation, options).serializable_hash end def update + if @accounts_invitation.expired? + render json: {code: 410, error_message: I18n.t("api.shopkeeper.accounts_invitations.expired")}, status: :gone + return + end + if @accounts_invitation.accept!(current_shopkeeper) render json: {status: 200}, status: :ok else diff --git a/app/models/accounts_invitation.rb b/app/models/accounts_invitation.rb index 60ec2bf..4a45c26 100644 --- a/app/models/accounts_invitation.rb +++ b/app/models/accounts_invitation.rb @@ -1,5 +1,6 @@ class AccountsInvitation < ApplicationRecord ROLES = AccountsShopkeeper::ROLES + EXPIRES_IN = ConfigSettings.accounts_invitation.expires_in_hours.hours include Rolified @@ -11,6 +12,13 @@ class AccountsInvitation < ApplicationRecord before_create :set_token + scope :active, -> { where(created_at: EXPIRES_IN.ago..) } + scope :expired, -> { where(created_at: ...EXPIRES_IN.ago) } + + def expired? + created_at < EXPIRES_IN.ago + end + def save_and_send_invite save && send_invite end @@ -19,6 +27,11 @@ def send_invite Shopkeeper::NotificationMailer.with(accounts_invitation: self).invited.deliver_later end + def resend_invite + touch(:created_at) + send_invite + end + def accept!(shopkeeper) accounts_shopkeeper = account.accounts_shopkeepers.new(shopkeeper: shopkeeper, roles: roles) if accounts_shopkeeper.valid? diff --git a/app/views/shopkeeper/notification_mailer/invited.html.erb b/app/views/shopkeeper/notification_mailer/invited.html.erb index 49c3c6a..dcf4057 100644 --- a/app/views/shopkeeper/notification_mailer/invited.html.erb +++ b/app/views/shopkeeper/notification_mailer/invited.html.erb @@ -22,3 +22,5 @@

<%= link_to ConfigSettings.mobile_app.name_ios, ConfigSettings.mobile_app.app_store_url, target: :_blank %>

<%= link_to ConfigSettings.mobile_app.name_android, ConfigSettings.mobile_app.play_store_url, target: :_blank %>

+ +

<%= t(".expires_in", expires_in: ConfigSettings.accounts_invitation.expires_in_hours) %>

diff --git a/app/views/shopkeeper/notification_mailer/invited.text.erb b/app/views/shopkeeper/notification_mailer/invited.text.erb index 8c636a4..24f3a4d 100644 --- a/app/views/shopkeeper/notification_mailer/invited.text.erb +++ b/app/views/shopkeeper/notification_mailer/invited.text.erb @@ -16,3 +16,5 @@ <%= ConfigSettings.mobile_app.name_ios %>: <%= ConfigSettings.mobile_app.app_store_url %> <%= ConfigSettings.mobile_app.name_android %>: <%= ConfigSettings.mobile_app.play_store_url %> + +<%= t(".expires_in", expires_in: ConfigSettings.accounts_invitation.expires_in_hours) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 2907d85..f1bd01d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -51,6 +51,7 @@ en: cannot_delete: "You cannot delete your personal account." accounts_invitations: not_found: "Whoops, we weren't able to find this invitation. Check with your organization admin for a new invitation." + expired: "This invitation has expired. Please ask the organization admin to resend the invitation." accounts_shopkeepers: require_non_personal_account: "Require non personal organization." item_tags: @@ -70,6 +71,7 @@ en: tap_join_button: "Tap Join button." enter_your_invitation_code: "Enter your invitation code below." if_you_accepted_invitation_switch_to_organization: "If you accepted the invitation, switch to the organization(Organizations > [Organization] > Switch)." + expires_in: "This invitation will expire in %{expires_in} hours." reset_password_instructions: subject: "Reset password instructions" request_reset_link_msg: "Someone has requested a link to change your password. You can do this through the link below." diff --git a/config/settings.yml b/config/settings.yml index 15da41d..af412bb 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -35,6 +35,9 @@ account: accounts_shopkeeper: limit_count: 99 +accounts_invitation: + expires_in_hours: 48 + item_tag: limit_count: 99 default_count: 10 diff --git a/config/settings/development.yml b/config/settings/development.yml index e69de29..57f9769 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -0,0 +1,2 @@ +accounts_invitation: + expires_in_hours: 0.083 # ~5 minutes diff --git a/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb b/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb index 8a209ee..9bbaf8d 100644 --- a/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb +++ b/test/controllers/api/v1/shopkeeper/accounts/accounts_invitations_controller_test.rb @@ -158,11 +158,16 @@ class Api::V1::Shopkeeper::Accounts::AccountsInvitationsControllerTest < ActionD assert_response :unauthorized end - test "resend sends invitation email again" do - post resend_api_v1_shopkeeper_account_accounts_invitation_path(@account, @invitation.token), - headers: @shopkeeper.create_new_auth_token + test "resend sends invitation email again and touches created_at" do + original_created_at = @invitation.created_at - assert_response :success + travel_to(1.hour.from_now) do + post resend_api_v1_shopkeeper_account_accounts_invitation_path(@account, @invitation.token), + headers: @shopkeeper.create_new_auth_token + + assert_response :success + assert @invitation.reload.created_at > original_created_at + end end test "resend requires admin role" do diff --git a/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb b/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb index 89735ca..2046897 100644 --- a/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb +++ b/test/controllers/api/v1/shopkeeper/accounts_invitations_controller_test.rb @@ -69,6 +69,27 @@ class Api::V1::Shopkeeper::AccountsInvitationsControllerTest < ActionDispatch::I assert_response :success end + test "show returns 410 gone for expired invitation" do + @invitation.update_column(:created_at, (AccountsInvitation::EXPIRES_IN + 1.minute).ago) + + get api_v1_shopkeeper_accounts_invitation_url(@invitation.token), + headers: @shopkeeper.create_new_auth_token + + assert_response :gone + assert_equal I18n.t("api.shopkeeper.accounts_invitations.expired"), response.parsed_body["error_message"] + end + + test "update returns 410 gone for expired invitation" do + @invitation.update_column(:created_at, (AccountsInvitation::EXPIRES_IN + 1.minute).ago) + + other_shopkeeper = shopkeepers(:two) + patch api_v1_shopkeeper_accounts_invitation_url(@invitation.token), + headers: other_shopkeeper.create_new_auth_token + + assert_response :gone + assert_equal I18n.t("api.shopkeeper.accounts_invitations.expired"), response.parsed_body["error_message"] + end + test "requires authentication" do get api_v1_shopkeeper_accounts_invitation_url(@invitation.token) diff --git a/test/models/accounts_invitation_test.rb b/test/models/accounts_invitation_test.rb index 7d67486..31c9624 100644 --- a/test/models/accounts_invitation_test.rb +++ b/test/models/accounts_invitation_test.rb @@ -274,4 +274,69 @@ def setup invitation.send_invite end end + + test "expired? returns false for recent invitation" do + invitation = AccountsInvitation.create!( + account: @account, + name: "Invited User", + email: "recent@example.com", + junior_member: true + ) + + assert_not invitation.expired? + end + + test "expired? returns true for old invitation" do + invitation = AccountsInvitation.create!( + account: @account, + name: "Invited User", + email: "old@example.com", + junior_member: true + ) + + travel_to(AccountsInvitation::EXPIRES_IN.from_now + 1.minute) do + assert invitation.expired? + end + end + + test "active scope returns non-expired invitations" do + active_invitation = AccountsInvitation.create!( + account: @account, + name: "Active User", + email: "active_scope@example.com", + junior_member: true + ) + + expired_invitation = AccountsInvitation.create!( + account: @account, + name: "Expired User", + email: "expired_scope@example.com", + junior_member: true + ) + expired_invitation.update_column(:created_at, (AccountsInvitation::EXPIRES_IN + 1.minute).ago) + + active_invitations = AccountsInvitation.active + assert_includes active_invitations, active_invitation + assert_not_includes active_invitations, expired_invitation + end + + test "resend_invite touches created_at and sends invite" do + invitation = AccountsInvitation.create!( + account: @account, + name: "Invited User", + email: "resend@example.com", + invited_by: @shopkeeper, + junior_member: true + ) + + original_created_at = invitation.created_at + + travel_to(1.hour.from_now) do + assert_nothing_raised do + invitation.resend_invite + end + + assert invitation.created_at > original_created_at + end + end end