diff --git a/decidim-accountability/spec/system/preview_accountability_with_share_token_spec.rb b/decidim-accountability/spec/system/preview_accountability_with_share_token_spec.rb index e3701b7eace2..52e838eac9c9 100644 --- a/decidim-accountability/spec/system/preview_accountability_with_share_token_spec.rb +++ b/decidim-accountability/spec/system/preview_accountability_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview accountability with share token" do +describe "preview accountability with a share token" do let(:manifest_name) { "accountability" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-admin/app/commands/decidim/admin/create_share_token.rb b/decidim-admin/app/commands/decidim/admin/create_share_token.rb new file mode 100644 index 000000000000..85a3669d6672 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/create_share_token.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command with all the business logic to create a share token. + # This command is called from the controller. + class CreateShareToken < Decidim::Commands::CreateResource + fetch_form_attributes :token, :expires_at, :registered_only, :organization, :user, :token_for + + protected + + def resource_class = Decidim::ShareToken + + def extra_params + { + participatory_space: { + title: participatory_space&.title + }, + resource: { + title: component&.name + } + } + end + + def participatory_space + return form.token_for if form.token_for.try(:manifest).is_a?(Decidim::ParticipatorySpaceManifest) + return current_participatory_space if respond_to?(:current_participatory_space) + + component&.participatory_space + end + + def component + return form.token_for if form.token_for.is_a?(Decidim::Component) + + form.token_for.try(:component) + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/destroy_share_token.rb b/decidim-admin/app/commands/decidim/admin/destroy_share_token.rb new file mode 100644 index 000000000000..f4b9371aabfc --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/destroy_share_token.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command with all the business logic to destroy a share token. + # This command is called from the controller. + class DestroyShareToken < Decidim::Commands::DestroyResource + delegate :participatory_space, :component, to: :resource + + def extra_params + { + participatory_space: { + title: participatory_space&.title + }, + resource: { + title: component&.name + } + } + end + end + end +end diff --git a/decidim-admin/app/commands/decidim/admin/update_share_token.rb b/decidim-admin/app/commands/decidim/admin/update_share_token.rb new file mode 100644 index 000000000000..0a3bfee53db8 --- /dev/null +++ b/decidim-admin/app/commands/decidim/admin/update_share_token.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Decidim + module Admin + # A command with all the business logic to update a share token. + # This command is called from the controller. + class UpdateShareToken < Decidim::Commands::UpdateResource + fetch_form_attributes :expires_at, :registered_only + + delegate :participatory_space, :component, to: :resource + + def extra_params + { + participatory_space: { + title: participatory_space&.title + }, + resource: { + title: component&.name + } + } + end + end + end +end diff --git a/decidim-admin/app/controllers/decidim/admin/share_tokens_controller.rb b/decidim-admin/app/controllers/decidim/admin/share_tokens_controller.rb index 273fcf34fc28..abe6e9a1d043 100644 --- a/decidim-admin/app/controllers/decidim/admin/share_tokens_controller.rb +++ b/decidim-admin/app/controllers/decidim/admin/share_tokens_controller.rb @@ -2,11 +2,66 @@ module Decidim module Admin + # This is an abstract controller allows sharing unpublished things. + # Final implementation must inherit from this controller and implement the `resource` method. class ShareTokensController < Decidim::Admin::ApplicationController + include Decidim::Admin::Filterable + + helper_method :current_token, :resource, :resource_title, :share_tokens_path + + def index + enforce_permission_to :read, :share_tokens + @share_tokens = filtered_collection + end + + def new + enforce_permission_to :create, :share_tokens + @form = form(ShareTokenForm).instance + end + + def create + enforce_permission_to :create, :share_tokens + @form = form(ShareTokenForm).from_params(params, resource:) + + CreateShareToken.call(@form) do + on(:ok) do + flash[:notice] = I18n.t("share_tokens.create.success", scope: "decidim.admin") + redirect_to share_tokens_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("share_tokens.create.invalid", scope: "decidim.admin") + render action: "new" + end + end + end + + def edit + enforce_permission_to(:update, :share_tokens, share_token: current_token) + @form = form(ShareTokenForm).from_model(current_token) + end + + def update + enforce_permission_to(:update, :share_tokens, share_token: current_token) + @form = form(ShareTokenForm).from_params(params, resource:) + + UpdateShareToken.call(@form, current_token) do + on(:ok) do + flash[:notice] = I18n.t("share_tokens.update.success", scope: "decidim.admin") + redirect_to share_tokens_path + end + + on(:invalid) do + flash.now[:alert] = I18n.t("share_tokens.update.error", scope: "decidim.admin") + render :edit + end + end + end + def destroy - enforce_permission_to(:destroy, :share_token, share_token:) + enforce_permission_to(:destroy, :share_tokens, share_token: current_token) - Decidim::Commands::DestroyResource.call(share_token, current_user) do + DestroyShareToken.call(current_token, current_user) do on(:ok) do flash[:notice] = I18n.t("share_tokens.destroy.success", scope: "decidim.admin") end @@ -15,15 +70,62 @@ def destroy end end - redirect_back(fallback_location: root_path) + redirect_to share_tokens_path end private - def share_token - @share_token ||= Decidim::ShareToken.where( - organization: current_organization - ).find(params[:id]) + # override this method in the destination controller to specify the resource associated with the shared token (ie: a component) + def resource + raise NotImplementedError + end + + # Override also this method if resource does not respond to a translatable name or title + def resource_title + translated_attribute(resource.try(:name) || resource.title) + end + + # sets the prefix for the route helper methods (this may vary depending on the resource type) + # This setup works fine for participatory spaces and components, override if needed + def route_name + @route_name ||= "#{resource.manifest.route_name}_" + end + + def route_proxy + @route_proxy ||= EngineRouter.admin_proxy(resource.try(:participatory_space) || resource) + end + + # returns the proper path for managing a share token according to the resource + # this works fine for components and participatory spaces, override if needed + def share_tokens_path(method = :index, options = {}) + args = resource.is_a?(Decidim::Component) ? [resource, options] : [options] + + case method + when :index, :create + route_proxy.send("#{route_name}share_tokens_path", *args) + when :new + route_proxy.send("new_#{route_name}share_token_path", *args) + when :update, :destroy + route_proxy.send("#{route_name}share_token_path", *args) + when :edit + route_proxy.send("edit_#{route_name}share_token_path", *args) + end + end + + def base_query + collection + end + + def collection + @collection ||= Decidim::ShareToken.where(organization: current_organization, token_for: resource) + end + + def filters + [] + end + + def current_token + @current_token ||= collection.find(params[:id]) end end end diff --git a/decidim-admin/app/forms/decidim/admin/share_token_form.rb b/decidim-admin/app/forms/decidim/admin/share_token_form.rb new file mode 100644 index 000000000000..849d52f6259b --- /dev/null +++ b/decidim-admin/app/forms/decidim/admin/share_token_form.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Decidim + module Admin + class ShareTokenForm < Decidim::Form + mimic :share_token + + attribute :token, String + attribute :automatic_token, Boolean, default: true + attribute :expires_at, Decidim::Attributes::TimeWithZone + attribute :no_expiration, Boolean, default: true + attribute :registered_only, Boolean, default: false + + validates :token, presence: true, if: ->(form) { form.automatic_token.blank? } + validate :token_uniqueness, if: ->(form) { form.automatic_token.blank? } + + validates_format_of :token, with: /\A[a-zA-Z0-9_-]+\z/, allow_blank: true + validates :expires_at, presence: true, if: ->(form) { form.no_expiration.blank? } + + def map_model(model) + self.no_expiration = model.expires_at.blank? + end + + def token + super.strip.upcase.gsub(/\s+/, "-") if super.present? + end + + def expires_at + return nil if no_expiration.present? + + super + end + + def token_for + context[:resource] + end + + def organization + context[:current_organization] + end + + def user + context[:current_user] + end + + private + + def token_uniqueness + return unless Decidim::ShareToken.where(organization:, token_for:, token:).where.not(id:).any? + + errors.add(:token, :taken) + end + end + end +end diff --git a/decidim-admin/app/views/decidim/admin/components/_actions.html.erb b/decidim-admin/app/views/decidim/admin/components/_actions.html.erb index 3d6172aabea4..b899e6f932ef 100644 --- a/decidim-admin/app/views/decidim/admin/components/_actions.html.erb +++ b/decidim-admin/app/views/decidim/admin/components/_actions.html.erb @@ -4,11 +4,12 @@ <% end %> -<% if allowed_to? :share, :component, component: component %> - <%= icon_link_to "share-line", url_for(action: :share, id: component, controller: "components"), t("actions.share", scope: "decidim.admin"), target: :blank, class: "action-icon--share" %> +<% if component.manifest.admin_engine && allowed_to?(:share, :component, component: component) %> + <%= icon_link_to "share-line", component_share_tokens_path(component_id: component), t("actions.share_tokens", scope: "decidim.admin"), class: "action-icon--share" %> <% else %> <% end %> + <% if allowed_to? :update, :component, component: component %> <%= icon_link_to "settings-4-line", url_for(action: :edit, id: component, controller: "components"), t("actions.configure", scope: "decidim.admin"), class: "action-icon--configure" %> <% else %> diff --git a/decidim-admin/app/views/decidim/admin/components/_form.html.erb b/decidim-admin/app/views/decidim/admin/components/_form.html.erb index b317fd526860..96c300888850 100644 --- a/decidim-admin/app/views/decidim/admin/components/_form.html.erb +++ b/decidim-admin/app/views/decidim/admin/components/_form.html.erb @@ -111,13 +111,4 @@ <% end %> - <% if component && component.persisted? && !component.published? %> -
-
-
- <%= render partial: "decidim/admin/share_tokens/share_tokens", locals: { share_tokens: form.object.share_tokens } %> -
-
-
- <% end %> diff --git a/decidim-admin/app/views/decidim/admin/share_tokens/_form.html.erb b/decidim-admin/app/views/decidim/admin/share_tokens/_form.html.erb new file mode 100644 index 000000000000..5b6e0be978c4 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/share_tokens/_form.html.erb @@ -0,0 +1,52 @@ +
+ + <%= form.collection_radio_buttons :no_expiration, [[true, t("share_tokens.form.never_expire", scope: "decidim.admin")], [false, t("share_tokens.form.custom", scope: "decidim.admin")]], :first, :last do |b| %> +
+ <%= b.radio_button %> + <%= b.label %> +
+ <% end %> + +
+ +
+ + <%= form.collection_radio_buttons :registered_only, [ + [t("share_tokens.form.true", scope: "decidim.admin"), true], + [t("share_tokens.form.false", scope: "decidim.admin"), false] + ], :last, :first do |b| %> +
+ <%= b.label do %> + <%= b.radio_button %> + <%= b.text %> + <% end %> +
+ <% end %> +
+ + diff --git a/decidim-admin/app/views/decidim/admin/share_tokens/_share_tokens.html.erb b/decidim-admin/app/views/decidim/admin/share_tokens/_share_tokens.html.erb deleted file mode 100644 index 4b9d7999c96f..000000000000 --- a/decidim-admin/app/views/decidim/admin/share_tokens/_share_tokens.html.erb +++ /dev/null @@ -1,45 +0,0 @@ -
-
- <%= t ".title" %> -
-
-
-

<%= t ".help" %>

-
- <% if share_tokens.any? %> -
- - - - - - - - - - - - - - <% share_tokens.each do |share_token| %> - - - - - - - - - - <% end %> - -
<%= t("models.share_token.fields.token", scope: "decidim.admin") %><%= t("models.share_token.fields.user", scope: "decidim.admin") %><%= t("models.share_token.fields.times_used", scope: "decidim.admin") %><%= t("models.share_token.fields.last_used_at", scope: "decidim.admin") %><%= t("models.share_token.fields.created_at", scope: "decidim.admin") %><%= t("models.share_token.fields.expires_at", scope: "decidim.admin") %>
<%= share_token.token %><%= share_token.user.name %><%= share_token.times_used %><%= l share_token.last_used_at, format: :short if share_token.last_used_at.present? %><%= l share_token.created_at, format: :short %><%= l share_token.expires_at, format: :short if share_token.expires_at.present? %> - <%= icon_link_to "share-line", share_token.url, t("actions.share", scope: "decidim.admin.share_tokens"), class: "action-icon--share", target: :blank %> - <%= icon_link_to "delete-bin-line", decidim_admin.share_token_path(share_token, url: request.fullpath), t("actions.destroy", scope: "decidim.admin.share_tokens"), class: "action-icon--remove", method: :delete, data: { confirm: t("actions.confirm_destroy", scope: "decidim.admin.share_tokens") } %> -
-
- <% else %> -

<%= t ".empty" %>

- <% end %> -
-
diff --git a/decidim-admin/app/views/decidim/admin/share_tokens/edit.html.erb b/decidim-admin/app/views/decidim/admin/share_tokens/edit.html.erb new file mode 100644 index 000000000000..350e59e0ff4d --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/share_tokens/edit.html.erb @@ -0,0 +1,33 @@ +<% add_decidim_page_title(t(".title", name: resource_title)) %> + +
+

+ <%= t ".title", name: resource_title %> + <%= t("share_tokens.index.back_to_share_tokens", scope: "decidim.admin") %> +

+
+
+ <%= decidim_form_for(@form, url: share_tokens_path(:update, id: current_token), html: { class: "form-defaults form edit_share_token" }) do |f| %> +
+
+
+
+
+ +
+ <%= text_field_tag :token, current_token.token, id: "share_token-token", aria: { label: t("token", scope: "decidim.admin.models.share_token.fields") }, disabled: true %> + +
+
+ <%= render partial: "form", object: f %> +
+
+
+
+
+
+ <%= f.submit t(".update"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
diff --git a/decidim-admin/app/views/decidim/admin/share_tokens/index.html.erb b/decidim-admin/app/views/decidim/admin/share_tokens/index.html.erb new file mode 100644 index 000000000000..c98cbcb7db29 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/share_tokens/index.html.erb @@ -0,0 +1,47 @@ +
+
+

+ <%= t(".title", name: resource_title) %> + <%= icon "add-line" %><%= t(".new_share_token_button") %> +

+
+
+

<%= t(".share_tokens_help_html", clipboard: icon("clipboard-line", class: "inline-block mb-1")) %>

+
+ <% if @share_tokens.any? %> +
+ + + + + + + + + + + + <% @share_tokens.each do |share_token| %> + + + + + + + + <% end %> + +
<%= sort_link(query, :token, t("models.share_token.fields.token", scope: "decidim.admin"), default_order: :desc) %><%= sort_link(query, :expires_at, t("models.share_token.fields.expires_at", scope: "decidim.admin"), default_order: :desc) %><%= sort_link(query, :registered_only, t("models.share_token.fields.registered_only", scope: "decidim.admin"), default_order: :desc) %><%= sort_link(query, :times_used, t("models.share_token.fields.times_used", scope: "decidim.admin"), default_order: :desc) %><%= t("models.share_token.fields.actions", scope: "decidim.admin") %>
<%= share_token.token %><%= share_token.expires_at.present? ? + content_tag(:span, l(share_token.expires_at, format: :decidim_short), class: share_token.expired? ? "text-warning" : nil ) : + content_tag(:em, t(".never")) %><%= t("booleans.#{share_token.registered_only.present?}") %><%= share_token.times_used %> + <%= icon_link_to "pencil-line", share_tokens_path(:edit, id: share_token ), t("actions.edit", scope: "decidim.admin.share_tokens"), class: "action-icon--edit" %> + <%= icon_link_to "clipboard-line", "#", t("actions.copy_link", scope: "decidim.admin.share_tokens"), class: "action-icon--copy", data: { "clipboard-copy" => "#js-token-#{share_token.id}", "clipboard-content" => share_token.url,"clipboard-copy-label" => t(".copied"),"clipboard-copy-message" => t(".copy_message") } %> + <%= icon_link_to "eye-line", share_token.url, t("actions.preview", scope: "decidim.admin.share_tokens"), class: "action-icon--preview", target: :blank %> + <%= icon_link_to "delete-bin-line", share_tokens_path(:destroy, id: share_token ), t("actions.destroy", scope: "decidim.admin.share_tokens"), class: "action-icon--remove", method: :delete, data: { confirm: t("actions.confirm_destroy", scope: "decidim.admin.share_tokens") } %> +
+
+ <% else %> +

<%= t(".empty_html", new_token_link: link_to(t(".create_new_token"), share_tokens_path(:new) , class: "button button__text-secondary")) %>

+ <% end %> +
+<%= decidim_paginate @share_tokens %> diff --git a/decidim-admin/app/views/decidim/admin/share_tokens/new.html.erb b/decidim-admin/app/views/decidim/admin/share_tokens/new.html.erb new file mode 100644 index 000000000000..69b0eb638ca8 --- /dev/null +++ b/decidim-admin/app/views/decidim/admin/share_tokens/new.html.erb @@ -0,0 +1,69 @@ +<% add_decidim_page_title(t(".title", name: resource_title)) %> + +
+

+ <%= t ".title", name: resource_title %> + <%= t("share_tokens.index.back_to_share_tokens", scope: "decidim.admin") %> +

+
+
+ <%= decidim_form_for(@form, url: share_tokens_path(:create), html: { class: "form-defaults form new_share_token" }) do |f| %> +
+
+
+
+
+ + <%= f.collection_radio_buttons :automatic_token, [ + [t("share_tokens.form.automatic", scope: "decidim.admin"), true], + [t("share_tokens.form.custom", scope: "decidim.admin"), false] + ], :last, :first do |b| %> +
+ <%= b.label do %> + <%= b.radio_button %> + <%= b.text %> + <% end %> +
+ <% end %> + +
+ <%= render partial: "form", object: f %> +
+
+
+
+
+
+ <%= f.submit t(".create"), class: "button button__sm button__secondary" %> +
+
+ <% end %> +
+ + diff --git a/decidim-admin/config/locales/en.yml b/decidim-admin/config/locales/en.yml index 6738a687c555..5333b899fec5 100644 --- a/decidim-admin/config/locales/en.yml +++ b/decidim-admin/config/locales/en.yml @@ -198,6 +198,7 @@ en: reject: Reject send_me_a_test_email: Send me a test email share: Share + share_tokens: Access links user: new: New admin verify: Verify @@ -599,6 +600,7 @@ en: scopes: Scopes see_site: See site settings: Settings + share_tokens: Access links static_page_topics: Topics static_pages: Pages taxonomies: Taxonomies @@ -656,12 +658,11 @@ en: plural: Plural share_token: fields: - created_at: Created at + actions: Actions expires_at: Expires at - last_used_at: Last time used + registered_only: Registered only? times_used: Times used - token: Token - user: Created by + token: Access link static_page: fields: created_at: Created at @@ -968,16 +969,50 @@ en: success: Scope updated successfully share_tokens: actions: - confirm_destroy: Are you sure you want to delete this token? + confirm_destroy: Are you sure you want to delete this access? + copy_link: Copy link destroy: Delete - share: Share + edit: Edit + preview: Preview + create: + invalid: There was a problem generating the access link. + success: Access link created successfully. destroy: - error: There was a problem destroying the token. - success: Token destroyed successfully. - share_tokens: - empty: There are no active tokens. - help: These tokens are used to publicly share this unpublished resource to any user. They will be hidden when the resource is published. Click on the token's share icon to visit the shareable URL. - title: Share tokens + error: There was a problem destroying the access link. + success: Access link successfully destroyed. + edit: + title: 'Edit access links for: %{name}' + update: Update + form: + automatic: Automatic + custom: Custom + custom_expiration: Custom expiration + custom_token: Custom word + expires_at: Expires at + 'false': 'No' + never_expire: Never + registered_only: Registered only? + token: Access key + 'true': 'Yes' + index: + back_to_share_tokens: Back to access links + copied: Access link Copied! + copy_message: The text was successfully copied to clipboard. + create_new_token: Create your first access link! + empty_html: There are no active access links. %{new_token_link} + never: Never + new_share_token_button: New access link + share_tokens_help_html: | + Create and share an access link to allow others to view this unpublished resource. + Access links can be valid for registered participants only or have and expiration date if necessary. + To share a new access link with someone, create it and then copy the link using the "%{clipboard} clipboard" action icon. + title: 'access links for: %{name}' + new: + create: Create + title: 'New access link for: %{name}' + update: + error: There was a problem updating this access. + success: Access link updated successfully. shared: adjacent_navigation: next: Next diff --git a/decidim-admin/config/routes.rb b/decidim-admin/config/routes.rb index 75dc6156ca15..4ed0667eefd3 100644 --- a/decidim-admin/config/routes.rb +++ b/decidim-admin/config/routes.rb @@ -109,8 +109,6 @@ put :accept end - resources :share_tokens, only: :destroy - resources :moderations, controller: "global_moderations" do member do put :unreport diff --git a/decidim-admin/spec/commands/decidim/admin/create_share_token_spec.rb b/decidim-admin/spec/commands/decidim/admin/create_share_token_spec.rb new file mode 100644 index 000000000000..4c43b5631c18 --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/create_share_token_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe CreateShareToken do + subject { described_class.new(form) } + + let(:organization) { create(:organization) } + let(:current_user) { create(:user, :admin, organization:) } + let(:component) { create(:component, participatory_space: create(:participatory_process, organization:)) } + + let(:form) do + ShareTokenForm.from_params( + token:, + expires_at:, + automatic_token:, + no_expiration:, + registered_only: + ).with_context( + current_user:, + current_organization: organization, + resource: component + ) + end + + let(:token) { "ABC123" } + let(:expires_at) { Time.zone.today + 10.days } + let(:automatic_token) { false } + let(:no_expiration) { false } + let(:registered_only) { true } + let(:extra) do + { + participatory_space: { + title: component.participatory_space.title + }, + resource: { + title: component.name + } + } + end + + context "when the form is valid" do + it "creates a share token" do + expect { subject.call }.to change(Decidim::ShareToken, :count).by(1) + + share_token = Decidim::ShareToken.last + expect(share_token.token).to eq(token) + expect(share_token.expires_at).to eq(expires_at) + expect(share_token.registered_only).to be(true) + expect(share_token.organization).to eq(organization) + expect(share_token.user).to eq(current_user) + expect(share_token.token_for).to eq(component) + end + + it "broadcasts :ok with the resource" do + expect(subject).to receive(:broadcast).with(:ok, instance_of(Decidim::ShareToken)) + subject.call + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:create!) + .with(Decidim::ShareToken, current_user, + { + expires_at:, + registered_only:, + token:, + organization:, + token_for: component, + user: current_user + }, + extra) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end + + context "when the form is invalid" do + before do + allow(form).to receive(:invalid?).and_return(true) + end + + it "does not create a share token" do + expect { subject.call }.not_to(change(Decidim::ShareToken, :count)) + end + + it "broadcasts :invalid" do + expect(subject).to receive(:broadcast).with(:invalid) + subject.call + end + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/destroy_share_token_spec.rb b/decidim-admin/spec/commands/decidim/admin/destroy_share_token_spec.rb new file mode 100644 index 000000000000..1c66c2d3b77d --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/destroy_share_token_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe DestroyShareToken do + subject { described_class.new(share_token, user) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let(:share_token) { create(:share_token, organization:, user:) } + let(:extra) do + { + participatory_space: { + title: share_token.participatory_space.title + }, + resource: { + title: share_token.component.name + } + } + end + + it "destroys the share_token" do + subject.call + expect { share_token.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + it "broadcasts ok" do + expect do + subject.call + end.to broadcast(:ok) + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:perform_action!) + .with(:delete, share_token, user, extra) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + end + end +end diff --git a/decidim-admin/spec/commands/decidim/admin/update_share_token_spec.rb b/decidim-admin/spec/commands/decidim/admin/update_share_token_spec.rb new file mode 100644 index 000000000000..db92c4319fba --- /dev/null +++ b/decidim-admin/spec/commands/decidim/admin/update_share_token_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe UpdateShareToken do + subject { described_class.new(form, share_token) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, organization:) } + let(:component) { create(:component, participatory_space: create(:participatory_process, organization:)) } + let!(:share_token) { create(:share_token, organization:, user:, token_for: component) } + + let(:form) do + ShareTokenForm.from_params( + token:, + expires_at:, + automatic_token:, + no_expiration:, + registered_only: + ).with_context( + current_user: user, + current_organization: organization, + resource: component + ) + end + + let(:token) { "ABCDEF97765544" } + let(:expires_at) { Time.zone.today + 20.days } + let(:automatic_token) { false } + let(:no_expiration) { false } + let(:registered_only) { false } + let(:extra) do + { + participatory_space: { + title: component.participatory_space.title + }, + resource: { + title: component.name + } + } + end + + context "when the form is valid" do + it "updates the expiration date" do + expect { subject.call }.to change { share_token.reload.expires_at }.to(expires_at) + .and change { share_token.reload.registered_only }.to(registered_only) + end + + it "broadcasts :ok with the resource" do + expect(subject).to receive(:broadcast).with(:ok, share_token) + subject.call + end + + it "traces the action", versioning: true do + expect(Decidim.traceability) + .to receive(:update!) + .with(share_token, user, { expires_at:, registered_only: }, extra) + .and_call_original + + expect { subject.call }.to change(Decidim::ActionLog, :count) + + action_log = Decidim::ActionLog.last + expect(action_log.version).to be_present + expect(action_log.version.event).to eq "update" + end + end + + context "when the form is invalid" do + before do + allow(form).to receive(:invalid?).and_return(true) + end + + it "does not update the share token" do + expect { subject.call }.not_to(change { share_token.reload.attributes }) + end + + it "broadcasts :invalid" do + expect(subject).to receive(:broadcast).with(:invalid) + subject.call + end + end + end +end diff --git a/decidim-admin/spec/forms/share_token_form_spec.rb b/decidim-admin/spec/forms/share_token_form_spec.rb new file mode 100644 index 000000000000..1bc0a0a4ae32 --- /dev/null +++ b/decidim-admin/spec/forms/share_token_form_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::Admin + describe ShareTokenForm do + let(:organization) { create(:organization) } + let(:current_user) { create(:user, :admin, organization:) } + let(:component) { create(:component, participatory_space: create(:participatory_process, organization:)) } + + let(:form) do + described_class.from_params( + token:, + automatic_token:, + expires_at:, + no_expiration:, + registered_only: + ).with_context( + current_user:, + current_organization: organization, + resource: component + ) + end + + let(:token) { "ABC123" } + let(:automatic_token) { true } + let(:expires_at) { Time.zone.today + 3.days } + let(:no_expiration) { false } + let(:registered_only) { true } + + it "returns defaults" do + expect(form.token).to eq("ABC123") + expect(form.automatic_token).to be(true) + expect(form.expires_at).to eq(Time.zone.today + 3.days) + expect(form.no_expiration).to be(false) + expect(form.registered_only).to be(true) + end + + context "when automatic_token validation is false" do + let(:automatic_token) { false } + + it "validates presence of token" do + form.token = nil + expect(form).to be_invalid + expect(form.errors[:token]).to include("cannot be blank") + end + + context "when automatic_token is set" do + let(:token) { "" } + let(:automatic_token) { true } + + it "does not validate presence of token" do + expect(form).to be_valid + end + end + end + + context "when expires_at is nil" do + let(:expires_at) { nil } + + it "validates presence of expires_at" do + expect(form).to be_invalid + expect(form.errors[:expires_at]).to include("cannot be blank") + end + + context "when no_expiration is set" do + let(:no_expiration) { true } + + it "does not expires" do + expect(form).to be_valid + end + end + end + + context "when token is custom" do + let(:token) { "abc 123 " } + + it "returns the token in uppercase" do + expect(form.token).to eq("ABC-123") + end + + context "and has strange characters" do + let(:token) { "abc 123 !@#$%^&*()_+" } + + it "returns the token in uppercase" do + expect(form).to be_invalid + expect(form.errors[:token]).to include("is invalid") + end + end + end + + context "when token exists" do + let(:automatic_token) { false } + let!(:share_token) { create(:share_token, organization:, token_for: component, token:) } + + it "validates uniqueness of token" do + expect(form).to be_invalid + expect(form.errors[:token]).to include("has already been taken") + end + end + + describe "#token_for" do + it "returns the component from the context" do + expect(form.token_for).to eq(component) + end + end + + describe "#organization" do + it "returns the current organization from the context" do + expect(form.organization).to eq(organization) + end + end + + describe "#user" do + it "returns the current user from the context" do + expect(form.user).to eq(current_user) + end + end + end +end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/assembly_share_tokens_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/assembly_share_tokens_controller.rb new file mode 100644 index 000000000000..9d1f1854d35b --- /dev/null +++ b/decidim-assemblies/app/controllers/decidim/assemblies/admin/assembly_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an assembly. + class AssemblyShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::AssemblyAdmin + + def resource + current_assembly + end + end + end + end +end diff --git a/decidim-assemblies/app/controllers/decidim/assemblies/admin/component_share_tokens_controller.rb b/decidim-assemblies/app/controllers/decidim/assemblies/admin/component_share_tokens_controller.rb new file mode 100644 index 000000000000..e03eb766121e --- /dev/null +++ b/decidim-assemblies/app/controllers/decidim/assemblies/admin/component_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an assembly. + class ComponentShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::AssemblyAdmin + + def resource + @resource ||= current_participatory_space.components.find(params[:component_id]) + end + end + end + end +end diff --git a/decidim-assemblies/app/models/decidim/assembly.rb b/decidim-assemblies/app/models/decidim/assembly.rb index d86fe42a8993..5c687805bf08 100644 --- a/decidim-assemblies/app/models/decidim/assembly.rb +++ b/decidim-assemblies/app/models/decidim/assembly.rb @@ -37,6 +37,7 @@ class Assembly < ApplicationRecord include Decidim::TranslatableResource include Decidim::HasArea include Decidim::FilterableResource + include Decidim::ShareableWithToken CREATED_BY = %w(city_council public others).freeze @@ -164,6 +165,10 @@ def self.ransackable_scopes(_auth_object = nil) [:with_any_area, :with_any_scope, :with_any_type] end + def shareable_url(share_token) + EngineRouter.main_proxy(self).assembly_url(self, share_token: share_token.token) + end + def self.ransackable_attributes(auth_object = nil) base = %w(title short_description description id) diff --git a/decidim-assemblies/app/permissions/decidim/assemblies/permissions.rb b/decidim-assemblies/app/permissions/decidim/assemblies/permissions.rb index 5c98dbd0224c..36b10640ab8a 100644 --- a/decidim-assemblies/app/permissions/decidim/assemblies/permissions.rb +++ b/decidim-assemblies/app/permissions/decidim/assemblies/permissions.rb @@ -134,6 +134,7 @@ def public_read_assembly_action? return disallow! unless can_view_private_space? return allow! if user&.admin? return allow! if assembly.published? + return allow! if user_can_preview_space? toggle_allow(can_manage_assembly?) end @@ -274,6 +275,7 @@ def assembly_admin_action? :assembly_user_role, :assembly_member, :export_space, + :share_tokens, :import ].include?(permission_action.subject) allow! if is_allowed @@ -293,11 +295,18 @@ def org_admin_action? :assembly_user_role, :assembly_member, :export_space, + :share_tokens, :import ].include?(permission_action.subject) allow! if is_allowed end + def user_can_preview_space? + context[:share_token].present? && Decidim::ShareToken.use!(token_for: assembly, token: context[:share_token], user:) + rescue ActiveRecord::RecordNotFound, StandardError + nil + end + # Checks if the permission_action is to read the admin assemblies list or # not. def read_assembly_list_permission_action? diff --git a/decidim-assemblies/app/views/decidim/assemblies/admin/assemblies/index.html.erb b/decidim-assemblies/app/views/decidim/assemblies/admin/assemblies/index.html.erb index 6773dfed6ce8..391c7c91a1d2 100644 --- a/decidim-assemblies/app/views/decidim/assemblies/admin/assemblies/index.html.erb +++ b/decidim-assemblies/app/views/decidim/assemblies/admin/assemblies/index.html.erb @@ -65,6 +65,7 @@ <% end %> + <% if assembly.published? %> <%= t("assemblies.index.published", scope: "decidim.admin") %> <% else %> @@ -72,6 +73,12 @@ <% end %> + <% if allowed_to? :read, :share_tokens, current_participatory_space: assembly %> + <%= icon_link_to "share-line", decidim_admin_assemblies.assembly_share_tokens_path(assembly), t("actions.share_tokens", scope: "decidim.admin"), class: "action-icon--new" %> + <% else %> + + <% end %> + <% if allowed_to? :update, :assembly, assembly: assembly %> <%= icon_link_to "pencil-line", edit_assembly_path(assembly), t("actions.configure", scope: "decidim.admin"), class: "action-icon--new" %> <% else %> diff --git a/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb b/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb index 7543dbdacb56..ee40545bb204 100644 --- a/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb +++ b/decidim-assemblies/lib/decidim/assemblies/admin_engine.rb @@ -58,6 +58,7 @@ class AdminEngine < ::Rails::Engine get :share put :hide end + resources :component_share_tokens, except: [:show], path: "share_tokens", as: "share_tokens" resources :exports, only: :create resources :imports, only: [:new, :create] do get :example, on: :collection @@ -84,6 +85,8 @@ class AdminEngine < ::Rails::Engine end end end + + resources :assembly_share_tokens, except: [:show], path: "share_tokens" end scope "/assemblies/:assembly_slug/components/:component_id/manage" do diff --git a/decidim-assemblies/lib/decidim/assemblies/menu.rb b/decidim-assemblies/lib/decidim/assemblies/menu.rb index 01aa9dccb672..511145c9fc94 100644 --- a/decidim-assemblies/lib/decidim/assemblies/menu.rb +++ b/decidim-assemblies/lib/decidim/assemblies/menu.rb @@ -79,6 +79,7 @@ def self.register_admin_assemblies_components_menu! active: is_active_link?(manage_component_path(component)) || is_active_link?(decidim_admin_assemblies.edit_component_path(current_participatory_space, component)) || is_active_link?(decidim_admin_assemblies.edit_component_permissions_path(current_participatory_space, component)) || + is_active_link?(decidim_admin_assemblies.component_share_tokens_path(current_participatory_space, component)) || participatory_space_active_link?(component), if: component.manifest.admin_engine && user_role_config.component_is_accessible?(component.manifest_name) end @@ -146,6 +147,13 @@ def self.register_admin_assembly_menu! decidim_admin_assemblies.moderations_path(current_participatory_space), icon_name: "flag-line", if: allowed_to?(:read, :moderation, assembly: current_participatory_space) + + menu.add_item :assembly_share_tokens, + I18n.t("menu.share_tokens", scope: "decidim.admin"), + decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space), + active: is_active_link?(decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space)), + icon_name: "share-line", + if: allowed_to?(:read, :share_tokens, current_participatory_space:) end end diff --git a/decidim-assemblies/spec/shared/manage_assembly_components_examples.rb b/decidim-assemblies/spec/shared/manage_assembly_components_examples.rb index 5ff30b7793c4..9c199587182c 100644 --- a/decidim-assemblies/spec/shared/manage_assembly_components_examples.rb +++ b/decidim-assemblies/spec/shared/manage_assembly_components_examples.rb @@ -210,8 +210,6 @@ } )) end - - it_behaves_like "manage component share tokens" end context "when the component is published" do diff --git a/decidim-assemblies/spec/system/admin/admin_manages_assembly_component_share_tokens_spec.rb b/decidim-assemblies/spec/system/admin/admin_manages_assembly_component_share_tokens_spec.rb new file mode 100644 index 000000000000..10bdf5dec0f5 --- /dev/null +++ b/decidim-assemblies/spec/system/admin/admin_manages_assembly_component_share_tokens_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages assembly component share tokens" do + include_context "when admin administrating an assembly" + + it_behaves_like "manage component share tokens" do + let(:participatory_space) { assembly } + let(:participatory_space_engine) { decidim_admin_assemblies } + end +end diff --git a/decidim-assemblies/spec/system/admin/admin_manages_assembly_share_tokens_spec.rb b/decidim-assemblies/spec/system/admin/admin_manages_assembly_share_tokens_spec.rb new file mode 100644 index 000000000000..aab9636ad0ee --- /dev/null +++ b/decidim-assemblies/spec/system/admin/admin_manages_assembly_share_tokens_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages assembly share tokens" do + let!(:user) { create(:user, :admin, :confirmed, organization:) } + let(:organization) { create(:organization) } + let!(:assembly) { create(:assembly, organization:, private_space: true) } + let(:participatory_space) { assembly } + let(:participatory_space_path) { decidim_admin_assemblies.edit_assembly_path(assembly) } + let(:participatory_spaces_path) { decidim_admin_assemblies.assemblies_path } + + it_behaves_like "manage participatory space share tokens" + + context "when the user is an assembly admin" do + let(:user) { create(:user, :confirmed, :admin_terms_accepted, organization:) } + let!(:role) { create(:assembly_user_role, user:, assembly:, role: :admin) } + + it_behaves_like "manage participatory space share tokens" + end +end diff --git a/decidim-assemblies/spec/system/preview_assembly_with_share_token_spec.rb b/decidim-assemblies/spec/system/preview_assembly_with_share_token_spec.rb new file mode 100644 index 000000000000..323eb2d49378 --- /dev/null +++ b/decidim-assemblies/spec/system/preview_assembly_with_share_token_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Preview assembly with share token" do + let(:organization) { create(:organization) } + let!(:participatory_space) { create(:assembly, organization:, published_at: nil) } + let(:resource_path) { decidim_assemblies.assembly_path(participatory_space) } + + it_behaves_like "preview participatory space with a share_token" +end diff --git a/decidim-blogs/spec/system/preview_blogs_with_share_token_spec.rb b/decidim-blogs/spec/system/preview_blogs_with_share_token_spec.rb index a5ff8c5cc36e..bc4669f1817d 100644 --- a/decidim-blogs/spec/system/preview_blogs_with_share_token_spec.rb +++ b/decidim-blogs/spec/system/preview_blogs_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview blogs with share token" do +describe "preview blogs with a share token" do let(:manifest_name) { "blogs" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-budgets/spec/system/preview_budgets_with_share_token_spec.rb b/decidim-budgets/spec/system/preview_budgets_with_share_token_spec.rb index e76e3bdd615b..dab5e601e529 100644 --- a/decidim-budgets/spec/system/preview_budgets_with_share_token_spec.rb +++ b/decidim-budgets/spec/system/preview_budgets_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview budgets with share token" do +describe "preview budgets with a share token" do let(:manifest_name) { "budgets" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-conferences/app/controllers/decidim/conferences/admin/component_share_tokens_controller.rb b/decidim-conferences/app/controllers/decidim/conferences/admin/component_share_tokens_controller.rb new file mode 100644 index 000000000000..af15a8a3ad9c --- /dev/null +++ b/decidim-conferences/app/controllers/decidim/conferences/admin/component_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Conferences + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an conference. + class ComponentShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::ConferenceAdmin + + def resource + @resource ||= current_participatory_space.components.find(params[:component_id]) + end + end + end + end +end diff --git a/decidim-conferences/app/controllers/decidim/conferences/admin/conference_share_tokens_controller.rb b/decidim-conferences/app/controllers/decidim/conferences/admin/conference_share_tokens_controller.rb new file mode 100644 index 000000000000..f57398263240 --- /dev/null +++ b/decidim-conferences/app/controllers/decidim/conferences/admin/conference_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Conferences + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an conference. + class ConferenceShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::ConferenceAdmin + + def resource + current_conference + end + end + end + end +end diff --git a/decidim-conferences/app/models/decidim/conference.rb b/decidim-conferences/app/models/decidim/conference.rb index 72615ee2b798..4ca329140809 100644 --- a/decidim-conferences/app/models/decidim/conference.rb +++ b/decidim-conferences/app/models/decidim/conference.rb @@ -20,6 +20,7 @@ class Conference < ApplicationRecord include Decidim::HasUploadValidations include Decidim::TranslatableResource include Decidim::FilterableResource + include Decidim::ShareableWithToken translatable_fields :title, :slogan, :short_description, :description, :objectives, :registration_terms @@ -146,6 +147,10 @@ def attachment_context :admin end + def shareable_url(share_token) + EngineRouter.main_proxy(self).conference_url(self, share_token: share_token.token) + end + # Allow ransacker to search for a key in a hstore column (`title`.`en`) ransacker_i18n :title diff --git a/decidim-conferences/app/permissions/decidim/conferences/permissions.rb b/decidim-conferences/app/permissions/decidim/conferences/permissions.rb index 5da0b9465f4c..167a3197d604 100644 --- a/decidim-conferences/app/permissions/decidim/conferences/permissions.rb +++ b/decidim-conferences/app/permissions/decidim/conferences/permissions.rb @@ -127,6 +127,7 @@ def public_read_conference_action? return allow! if user&.admin? return allow! if conference.published? + return allow! if user_can_preview_space? toggle_allow(can_manage_conference?) end @@ -276,7 +277,8 @@ def conference_admin_action? :partner, :media_link, :registration_type, - :conference_invite + :conference_invite, + :share_tokens ].include?(permission_action.subject) allow! if is_allowed end @@ -299,11 +301,18 @@ def org_admin_action? :partner, :registration_type, :read_conference_registrations, - :export_conference_registrations + :export_conference_registrations, + :share_tokens ].include?(permission_action.subject) allow! if is_allowed end + def user_can_preview_space? + context[:share_token].present? && Decidim::ShareToken.use!(token_for: conference, token: context[:share_token], user:) + rescue ActiveRecord::RecordNotFound, StandardError + nil + end + # Checks if the permission_action is to read the admin conferences list or # not. def read_conference_list_permission_action? diff --git a/decidim-conferences/app/views/decidim/conferences/admin/conferences/index.html.erb b/decidim-conferences/app/views/decidim/conferences/admin/conferences/index.html.erb index 66a678c1ddb1..992f1ef07092 100644 --- a/decidim-conferences/app/views/decidim/conferences/admin/conferences/index.html.erb +++ b/decidim-conferences/app/views/decidim/conferences/admin/conferences/index.html.erb @@ -38,6 +38,11 @@ <% end %> + <% if allowed_to? :read, :share_tokens, current_participatory_space: conference %> + <%= icon_link_to "share-line", decidim_admin_conferences.conference_share_tokens_path(conference), t("actions.share_tokens", scope: "decidim.admin"), class: "action-icon--new" %> + <% else %> + + <% end %> <% if allowed_to? :update, :conference, conference: conference %> <%= icon_link_to "pencil-line", edit_conference_path(conference), t("actions.configure", scope: "decidim.admin"), class: "action-icon--new" %> <% end %> diff --git a/decidim-conferences/lib/decidim/conferences/admin_engine.rb b/decidim-conferences/lib/decidim/conferences/admin_engine.rb index db2327892d0e..99e7c26a8338 100644 --- a/decidim-conferences/lib/decidim/conferences/admin_engine.rb +++ b/decidim-conferences/lib/decidim/conferences/admin_engine.rb @@ -70,6 +70,7 @@ class AdminEngine < ::Rails::Engine get :share put :hide end + resources :component_share_tokens, except: [:show], path: "share_tokens", as: "share_tokens" resources :exports, only: :create resources :imports, only: [:new, :create] do get :example, on: :collection @@ -85,6 +86,8 @@ class AdminEngine < ::Rails::Engine end resources :reports, controller: "moderations/reports", only: [:index, :show] end + + resources :conference_share_tokens, except: [:show], path: "share_tokens" end scope "/conferences/:conference_slug/components/:component_id/manage" do diff --git a/decidim-conferences/lib/decidim/conferences/menu.rb b/decidim-conferences/lib/decidim/conferences/menu.rb index a99250eda1b0..4fe7756cf0a4 100644 --- a/decidim-conferences/lib/decidim/conferences/menu.rb +++ b/decidim-conferences/lib/decidim/conferences/menu.rb @@ -48,6 +48,7 @@ def self.register_admin_conferences_components_menu! active: is_active_link?(manage_component_path(component)) || is_active_link?(decidim_admin_conferences.edit_component_path(current_participatory_space, component)) || is_active_link?(decidim_admin_conferences.edit_component_permissions_path(current_participatory_space, component)) || + is_active_link?(decidim_admin_conferences.component_share_tokens_path(current_participatory_space, component)) || participatory_space_active_link?(component), if: component.manifest.admin_engine && user_role_config.component_is_accessible?(component.manifest_name) end @@ -172,6 +173,13 @@ def self.register_conferences_admin_menu! decidim_admin_conferences.moderations_path(current_participatory_space), icon_name: "flag-line", if: allowed_to?(:read, :moderation, conference: current_participatory_space) + + menu.add_item :conference_share_tokens, + I18n.t("menu.share_tokens", scope: "decidim.admin"), + decidim_admin_conferences.conference_share_tokens_path(current_participatory_space), + active: is_active_link?(decidim_admin_conferences.conference_share_tokens_path(current_participatory_space)), + icon_name: "share-line", + if: allowed_to?(:read, :share_tokens, current_participatory_space:) end end diff --git a/decidim-conferences/spec/shared/manage_conference_components_examples.rb b/decidim-conferences/spec/shared/manage_conference_components_examples.rb index 5456a82a52cf..644c5d5a00f2 100644 --- a/decidim-conferences/spec/shared/manage_conference_components_examples.rb +++ b/decidim-conferences/spec/shared/manage_conference_components_examples.rb @@ -210,8 +210,6 @@ } )) end - - it_behaves_like "manage component share tokens" end context "when the component is published" do diff --git a/decidim-conferences/spec/system/admin/admin_manages_conference_component_share_tokens_spec.rb b/decidim-conferences/spec/system/admin/admin_manages_conference_component_share_tokens_spec.rb new file mode 100644 index 000000000000..faa819ab0fd9 --- /dev/null +++ b/decidim-conferences/spec/system/admin/admin_manages_conference_component_share_tokens_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages conference component share tokens" do + include_context "when admin administrating a conference" + + it_behaves_like "manage component share tokens" do + let(:participatory_space) { conference } + let(:participatory_space_engine) { decidim_admin_conferences } + end +end diff --git a/decidim-conferences/spec/system/admin/admin_manages_conference_share_tokens_spec.rb b/decidim-conferences/spec/system/admin/admin_manages_conference_share_tokens_spec.rb new file mode 100644 index 000000000000..827e50a37b56 --- /dev/null +++ b/decidim-conferences/spec/system/admin/admin_manages_conference_share_tokens_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages conference share tokens" do + include_context "when admin administrating a conference" + let(:participatory_space) { conference } + let(:participatory_space_path) { decidim_admin_conferences.edit_conference_path(conference) } + let(:participatory_spaces_path) { decidim_admin_conferences.conferences_path } + + it_behaves_like "manage participatory space share tokens" + + context "when the user is a conference admin" do + let(:user) { create(:user, :confirmed, :admin_terms_accepted, organization:) } + let!(:role) { create(:conference_user_role, user:, conference:, role: :admin) } + + it_behaves_like "manage participatory space share tokens" + end +end diff --git a/decidim-conferences/spec/system/preview_conference_with_share_token_spec.rb b/decidim-conferences/spec/system/preview_conference_with_share_token_spec.rb new file mode 100644 index 000000000000..714c761d7f5a --- /dev/null +++ b/decidim-conferences/spec/system/preview_conference_with_share_token_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Preview conference with share token" do + let(:organization) { create(:organization) } + let!(:participatory_space) { create(:conference, organization:, published_at: nil) } + let(:resource_path) { decidim_conferences.conference_path(participatory_space) } + + it_behaves_like "preview participatory space with a share_token" +end diff --git a/decidim-core/app/controllers/concerns/decidim/needs_permission.rb b/decidim-core/app/controllers/concerns/decidim/needs_permission.rb index c4b448a6623a..c12e91c0d318 100644 --- a/decidim-core/app/controllers/concerns/decidim/needs_permission.rb +++ b/decidim-core/app/controllers/concerns/decidim/needs_permission.rb @@ -40,7 +40,8 @@ def permissions_context current_settings: try(:current_settings), component_settings: try(:component_settings), current_organization: try(:current_organization), - current_component: try(:current_component) + current_component: try(:current_component), + share_token: try(:store_share_token) } end diff --git a/decidim-core/app/controllers/decidim/application_controller.rb b/decidim-core/app/controllers/decidim/application_controller.rb index 8b8cc44b564a..4e30a220c957 100644 --- a/decidim-core/app/controllers/decidim/application_controller.rb +++ b/decidim-core/app/controllers/decidim/application_controller.rb @@ -57,6 +57,12 @@ class ApplicationController < ::DecidimController skip_before_action :disable_http_caching, unless: :user_signed_in? + def store_share_token + session[:share_token] = params[:share_token] if params.has_key?(:share_token) + + session[:share_token].presence + end + private # This overrides Devise's method for extracting the path from the URL. We diff --git a/decidim-core/app/controllers/decidim/components/base_controller.rb b/decidim-core/app/controllers/decidim/components/base_controller.rb index 07e415c455d4..09bc0b75c6a7 100644 --- a/decidim-core/app/controllers/decidim/components/base_controller.rb +++ b/decidim-core/app/controllers/decidim/components/base_controller.rb @@ -30,7 +30,7 @@ class BaseController < Decidim::ApplicationController :current_manifest before_action do - enforce_permission_to :read, :component, component: current_component, share_token: + enforce_permission_to :read, :component, component: current_component end before_action :redirect_unless_feature_private @@ -49,10 +49,6 @@ def current_manifest @current_manifest ||= current_component.manifest end - def share_token - params[:share_token] - end - def permission_scope :public end diff --git a/decidim-core/app/controllers/decidim/homepage_controller.rb b/decidim-core/app/controllers/decidim/homepage_controller.rb index 6dd3c23367f0..a8784f8a8300 100644 --- a/decidim-core/app/controllers/decidim/homepage_controller.rb +++ b/decidim-core/app/controllers/decidim/homepage_controller.rb @@ -3,7 +3,6 @@ module Decidim class HomepageController < Decidim::ApplicationController skip_before_action :store_current_location - def show; end end end diff --git a/decidim-core/app/models/decidim/component.rb b/decidim-core/app/models/decidim/component.rb index 305109423649..ee80dfaaecc5 100644 --- a/decidim-core/app/models/decidim/component.rb +++ b/decidim-core/app/models/decidim/component.rb @@ -113,7 +113,7 @@ def private_non_transparent_space? # Public: Public URL for component with given share token as query parameter def shareable_url(share_token) - EngineRouter.main_proxy(self).root_path(self, share_token: share_token.token) + EngineRouter.main_proxy(self).root_url(self, share_token: share_token.token) end delegate :serializes_specific_data?, to: :manifest diff --git a/decidim-core/app/models/decidim/share_token.rb b/decidim-core/app/models/decidim/share_token.rb index 950207583ff1..4cd67777b242 100644 --- a/decidim-core/app/models/decidim/share_token.rb +++ b/decidim-core/app/models/decidim/share_token.rb @@ -2,33 +2,63 @@ module Decidim class ShareToken < ApplicationRecord - validates :token, presence: true, uniqueness: { scope: [:decidim_organization_id, :token_for_type, :token_for_id] } + include Decidim::Traceable belongs_to :organization, foreign_key: "decidim_organization_id", class_name: "Decidim::Organization" belongs_to :user, foreign_key: "decidim_user_id", class_name: "Decidim::User" belongs_to :token_for, foreign_type: "token_for_type", polymorphic: true - after_initialize :generate, :set_default_expiration + validates :token, presence: true, uniqueness: { scope: [:decidim_organization_id, :token_for_type, :token_for_id] } + # validates token no spaces or strange characters + validates :token, format: { with: /\A[a-zA-Z0-9_-]+\z/ } + + after_initialize :generate + + def self.log_presenter_class_for(_log) + Decidim::AdminLog::ShareTokenPresenter + end - def self.use!(token_for:, token:) + def self.use!(token_for:, token:, user: nil) record = find_by!(token_for:, token:) - record.use! + record.use!(user:) end - def use! + def use!(user: nil) return raise StandardError, "Share token '#{token}' for '#{token_for_type}' with id = #{token_for_id} has expired." if expired? + return raise StandardError, "Share token '#{token}' for '#{token_for_type}' with id = #{token_for_id} requires a registered user." if registered_only? && user.nil? update!(times_used: times_used + 1, last_used_at: Time.zone.now) end def expired? - expires_at.past? + expires_at.past? unless expires_at.nil? end def url token_for.shareable_url(self) end + def participatory_space + return token_for if token_for.try(:manifest).is_a?(Decidim::ParticipatorySpaceManifest) + return token_for.participatory_space if token_for.respond_to?(:participatory_space) + + component&.participatory_space + end + + def component + return token_for if token_for.is_a?(Decidim::Component) + + token_for.component if token_for.respond_to?(:component) + end + + def self.ransackable_attributes(_auth_object = nil) + %w(token expires_at last_used_at registered_only) + end + + def self.ransackable_associations(_auth_object = nil) + %w(organization token_for user) + end + private def generate @@ -39,9 +69,5 @@ def generate break if ShareToken.find_by(token:).blank? end end - - def set_default_expiration - self.expires_at ||= 1.day.from_now - end end end diff --git a/decidim-core/app/packs/src/decidim/clipboard.js b/decidim-core/app/packs/src/decidim/clipboard.js index 26327c8ac861..010190cb595f 100644 --- a/decidim-core/app/packs/src/decidim/clipboard.js +++ b/decidim-core/app/packs/src/decidim/clipboard.js @@ -21,7 +21,9 @@ import select from "select"; * * Options through data attributes: * - `data-clipboard-copy` = The jQuery selector for the target input element - * where text will be copied from. + * where text will be copied from. If this element does not contain any visible text (for instance is an image), + * the selector indicated in here will be used to place the confirmation message. + * - `data-clipboard-content` = The text that will be copied. If empty or not present, the target input element will be used. * - `data-clipboard-copy-label` = The label that will be shown in the button * after a succesful copy. * - `data-clipboard-copy-message` = The text that will be announced to screen @@ -40,12 +42,17 @@ $(() => { } const $input = $($el.data("clipboard-copy")); - if ($input.length < 1 || !$input.is("input, textarea, select")) { - return; + + let selectedText = $el.data("clipboard-content") || ""; + if (selectedText === "" && $input.is("input, textarea, select")) { + selectedText = select($input[0]); } + let $msgEl = $el; + if ($msgEl.text() === "") { + $msgEl = $input; + } // Get the available text to clipboard. - const selectedText = select($input[0]); if (!selectedText || selectedText.length < 1) { return; } @@ -83,16 +90,18 @@ $(() => { } if (!$el.data("clipboard-copy-label-original")) { - $el.data("clipboard-copy-label-original", $el.html()); + $el.data("clipboard-copy-label-original", $msgEl.html()); } - $el.html(label); + $msgEl.html(label); + to = setTimeout(() => { - $el.html($el.data("clipboard-copy-label-original")); + $msgEl.html($el.data("clipboard-copy-label-original")); $el.removeData("clipboard-copy-label-original"); $el.removeData("clipboard-copy-label-timeout"); }, CLIPBOARD_COPY_TIMEOUT); - $el.data("clipboard-copy-label-timeout", to) + + $el.data("clipboard-copy-label-timeout", to); } // Alert the screen reader what just happened (the link was copied). @@ -107,7 +116,7 @@ $(() => { } } else { $msg = $('
'); - $el.after($msg); + $msgEl.append($msg); $el.data("clipboard-message-element", $msg); } diff --git a/decidim-core/app/permissions/decidim/permissions.rb b/decidim-core/app/permissions/decidim/permissions.rb index cdb53ff8d0ad..9023202bb623 100644 --- a/decidim-core/app/permissions/decidim/permissions.rb +++ b/decidim-core/app/permissions/decidim/permissions.rb @@ -56,7 +56,6 @@ def component_public_action? return allow! if component.published? return allow! if user_can_preview_component? - return allow! if user_can_admin_component? return allow! if user_can_admin_component_via_space? disallow! @@ -163,7 +162,7 @@ def user_group_invitations_action? end def user_can_preview_component? - return allow! if context[:share_token].present? && Decidim::ShareToken.use!(token_for: component, token: context[:share_token]) + context[:share_token].present? && Decidim::ShareToken.use!(token_for: component, token: context[:share_token], user:) rescue ActiveRecord::RecordNotFound, StandardError nil end diff --git a/decidim-core/app/presenters/decidim/admin_log/share_token_presenter.rb b/decidim-core/app/presenters/decidim/admin_log/share_token_presenter.rb new file mode 100644 index 000000000000..1728e9030384 --- /dev/null +++ b/decidim-core/app/presenters/decidim/admin_log/share_token_presenter.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Decidim + module AdminLog + # This class extends the default resource presenter for logs, so that + # it can properly link to the static page. + class ShareTokenPresenter < Decidim::Log::BasePresenter + private + + def diff_fields_mapping + { + token: :string, + expires_at: :date, + registered_only: :boolean, + token_for: :string + } + end + + def action_string + case action + when "create", "delete", "update" + "decidim.admin_log.share_token.#{action}#{suffix}" + else + super + end + end + + def suffix + return "_with_space" if action_log.extra.dig("component", "title").present? + + "" + end + + def diff_actions + %w(update create delete) + end + end + end +end diff --git a/decidim-core/config/locales/en.yml b/decidim-core/config/locales/en.yml index b782659e0711..e0efe17f770c 100644 --- a/decidim-core/config/locales/en.yml +++ b/decidim-core/config/locales/en.yml @@ -263,6 +263,13 @@ en: create: "%{user_name} created the %{resource_name} scope type" delete: "%{user_name} deleted the %{resource_name} scope type" update: "%{user_name} updated the %{resource_name} scope type" + share_token: + create: "%{user_name} created an access link in %{space_name}" + create_with_space: "%{user_name} created an access link for %{resource_name} in %{space_name}" + delete: "%{user_name} deleted an access link in %{space_name}" + delete_with_space: "%{user_name} deleted an access link for %{resource_name} in %{space_name}" + update: "%{user_name} updated an access link in %{space_name}" + update_with_space: "%{user_name} updated an access link for %{resource_name} in %{space_name}" static_page: create: "%{user_name} created the %{resource_name} static page" delete: "%{user_name} deleted the %{resource_name} static page" diff --git a/decidim-core/db/migrate/20240717093514_add_registered_only_to_decidim_share_tokens.rb b/decidim-core/db/migrate/20240717093514_add_registered_only_to_decidim_share_tokens.rb new file mode 100644 index 000000000000..11417bf8e1b4 --- /dev/null +++ b/decidim-core/db/migrate/20240717093514_add_registered_only_to_decidim_share_tokens.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRegisteredOnlyToDecidimShareTokens < ActiveRecord::Migration[7.0] + def change + add_column :decidim_share_tokens, :registered_only, :boolean + end +end diff --git a/decidim-core/lib/decidim/component_manifest.rb b/decidim-core/lib/decidim/component_manifest.rb index 69604b84d5af..9f0f6fa00b3e 100644 --- a/decidim-core/lib/decidim/component_manifest.rb +++ b/decidim-core/lib/decidim/component_manifest.rb @@ -142,6 +142,13 @@ def seed!(participatory_space) @seeds&.call(participatory_space) end + # The name of the named Rails route to create the url to the resource. + # + # Returns a String. + def route_name + "component" + end + # Public: Adds configurable attributes for this component, scoped to a name. It # uses the DSL specified under `Decidim::SettingsManifest`. # diff --git a/decidim-core/lib/decidim/core/test.rb b/decidim-core/lib/decidim/core/test.rb index 6d1663329764..9cded50c36ce 100644 --- a/decidim-core/lib/decidim/core/test.rb +++ b/decidim-core/lib/decidim/core/test.rb @@ -61,8 +61,8 @@ require "decidim/core/test/shared_examples/permissions" require "decidim/core/test/shared_examples/admin_resource_gallery_examples" require "decidim/core/test/shared_examples/map_examples" -require "decidim/core/test/shared_examples/preview_component_with_share_token_examples" -require "decidim/core/test/shared_examples/manage_component_share_tokens" +require "decidim/core/test/shared_examples/preview_with_share_token_examples" +require "decidim/core/test/shared_examples/manage_share_tokens_examples" require "decidim/core/test/shared_examples/metric_manage_shared_context" require "decidim/core/test/shared_examples/resource_search_examples" require "decidim/core/test/shared_examples/static_pages_examples" diff --git a/decidim-core/lib/decidim/core/test/factories.rb b/decidim-core/lib/decidim/core/test/factories.rb index f251c3ec24ee..5642fa7fddc1 100644 --- a/decidim-core/lib/decidim/core/test/factories.rb +++ b/decidim-core/lib/decidim/core/test/factories.rb @@ -1014,6 +1014,10 @@ def generate_localized_title(field = nil, skip_injection: false) object.organization ||= object.token_for.organization end + trait :with_token do + token { SecureRandom.hex(32) } + end + trait :expired do expires_at { 1.day.ago } end diff --git a/decidim-core/lib/decidim/core/test/shared_examples/manage_component_share_tokens.rb b/decidim-core/lib/decidim/core/test/shared_examples/manage_component_share_tokens.rb deleted file mode 100644 index 4d6861669e4c..000000000000 --- a/decidim-core/lib/decidim/core/test/shared_examples/manage_component_share_tokens.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples "manage component share tokens" do - let!(:components_path) { participatory_space_components_path(participatory_space) } - - context "when visiting the components page for the participatory space" do - before do - visit components_path - end - - it "has a share button that opens the share url for the component" do - share_window = window_opened_by { click_on "Share", wait: 2 } - - within_window share_window do - expect(current_url).to include(component.share_tokens.reload.last.url) - end - end - end - - context "when visiting the component configuration page" do - context "when there are tokens" do - let!(:share_tokens) { create_list(:share_token, 3, token_for: component, organization: component.organization) } - let!(:share_token) { share_tokens.last } - - before do - visit components_path - - within "tr", text: component.name["en"] do - click_on "Configure" - end - end - - it "displays all tokens" do - within ".share_tokens" do - expect(page).to have_css("tbody tr", count: 3) - end - end - - it "displays relevant attributes for each token" do - share_tokens.each do |share_token| - within ".share_tokens tbody" do - expect(page).to have_content share_token.token - expect(page).to have_content share_token.user.name - end - end - end - - it "has a share link for each token" do - urls = share_tokens.map(&:url).map { |url| url.split("?").first } - within ".share_tokens tbody tr:first-child" do - share_window = window_opened_by { click_on "Share" } - - within_window share_window do - expect(urls).to include(page.current_path) - end - end - end - - it "has a link to delete tokens" do - within ".share_tokens tbody tr:first-child" do - accept_confirm { click_on "Delete" } - end - - expect(page).to have_admin_callout("successfully") - expect(page).to have_css("tbody tr", count: 2) - end - end - - context "when there are no tokens" do - before do - visit components_path - - within "tr", text: component.name["en"] do - click_on "Configure" - end - end - - it "displays empty message" do - expect(page).to have_content "There are no active tokens" - end - end - end -end diff --git a/decidim-core/lib/decidim/core/test/shared_examples/manage_share_tokens_examples.rb b/decidim-core/lib/decidim/core/test/shared_examples/manage_share_tokens_examples.rb new file mode 100644 index 000000000000..964277a197de --- /dev/null +++ b/decidim-core/lib/decidim/core/test/shared_examples/manage_share_tokens_examples.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +shared_examples "manage resource share tokens" do + context "when there are no tokens" do + let(:last_token) { Decidim::ShareToken.last } + before do + visit_share_tokens_page + end + + it "displays empty message" do + expect(page).to have_content "There are no active access links" + end + + it "can create a new token with default options" do + click_on "New access link" + + click_on "Create" + + expect(page).to have_content("Access link created successfully") + expect(page).to have_css("tbody tr", count: 1) + within "tbody tr:last-child td", text: last_token.token do + expect(page).to have_content(last_token.token) + end + within "tbody tr:last-child td:nth-child(2)" do + expect(page).to have_content("Never") + end + within "tbody tr:last-child td:nth-child(3)" do + expect(page).to have_content("No") + end + end + + it "can create a new token with custom options" do + click_on "New access link" + + find_by_id("share_token_automatic_token_false").click + find_by_id("share_token_no_expiration_false").click + find_by_id("share_token_registered_only_true").click + click_on "Create" + expect(page).to have_content("cannot be blank", count: 2) + + fill_in "share_token_token", with: " custom token " + fill_in_datepicker :share_token_expires_at_date, with: 1.day.from_now.strftime("%d/%m/%Y") + fill_in_timepicker :share_token_expires_at_time, with: "00:00" + click_on "Create" + + expect(page).to have_content("Access link created successfully") + expect(page).to have_css("tbody tr", count: 1) + within "tbody tr:last-child td", text: last_token.token do + expect(page).to have_content("CUSTOM-TOKEN") + end + within "tbody tr:last-child td:nth-child(2)" do + expect(page).to have_content(1.day.from_now.strftime("%d/%m/%Y 00:00")) + end + within "tbody tr:last-child td:nth-child(3)" do + expect(page).to have_content("Yes") + end + end + end + + context "when there are tokens" do + let!(:share_tokens) { create_list(:share_token, 3, :with_token, token_for: resource, organization:, registered_only: true) } + let(:last_token) { share_tokens.last } + + before do + visit_share_tokens_page + end + + it "displays all tokens" do + within ".share_tokens" do + expect(page).to have_css("tbody tr", count: 3) + end + end + + it "displays relevant attributes for each token" do + share_tokens.each do |share_token| + within ".share_tokens tbody" do + expect(page).to have_content share_token.token + expect(page).to have_content share_token.expires_at.to_s + end + end + end + + context "when ordering" do + let(:share_tokens) do + [ + create(:share_token, :with_token, token_for: resource, organization:, token: "b", expires_at: 1.day.from_now, registered_only: true, times_used: 3), + create(:share_token, :with_token, token_for: resource, organization:, token: "a", expires_at: 3.days.from_now, registered_only: true, times_used: 2), + create(:share_token, :with_token, token_for: resource, organization:, token: "c", expires_at: 2.days.from_now, registered_only: false, times_used: 1) + ] + end + + it "can be ordered by token and other attributes" do + within ".share_tokens" do + click_on "Access link" # order by token + expect(page).to have_css("tbody tr:first-child", text: "c") + click_on "Access link" # order by token + expect(page).to have_css("tbody tr:first-child", text: "a") + click_on "Expires at" # order by expires_at + expect(page).to have_css("tbody tr:first-child", text: share_tokens.second.expires_at.strftime("%d/%m/%Y %H:%M")) + click_on "Expires at" # order by expires_at + expect(page).to have_css("tbody tr:first-child", text: share_tokens.first.expires_at.strftime("%d/%m/%Y %H:%M")) + click_on "Registered only" # order by registered_only + expect(page).to have_css("tbody tr:first-child", text: "Yes") + click_on "Registered only" # order by registered_only + expect(page).to have_css("tbody tr:first-child", text: "No") + click_on "Times used" # order by times_used + expect(page).to have_css("tbody tr:first-child", text: "3") + click_on "Times used" # order by times_used + expect(page).to have_css("tbody tr:first-child", text: "1") + end + end + end + + it "can edit a share token" do + within "tbody tr", text: last_token.token do + expect(page).to have_content("Yes") + end + within ".share_tokens tbody tr", text: last_token.token do + click_on "Edit" + end + + expect(page).to have_content("Edit access links for: #{resource_name}") + find_by_id("share_token_no_expiration_false").click + find_by_id("share_token_registered_only_false").click + click_on "Update" + expect(page).to have_content("cannot be blank", count: 1) + + fill_in_datepicker :share_token_expires_at_date, with: 1.day.from_now.strftime("%d/%m/%Y") + fill_in_timepicker :share_token_expires_at_time, with: "00:00" + + click_on "Update" + + expect(page).to have_content("Access link updated successfully") + expect(page).to have_css("tbody tr", count: 3) + within "tbody tr", text: last_token.token do + expect(page).to have_content(1.day.from_now.strftime("%d/%m/%Y 00:00")) + end + within "tbody tr", text: last_token.token do + expect(page).to have_content("No") + end + end + + it "allows copying the share link from the share token" do + within ".share_tokens tbody tr", text: last_token.token do + click_on "Copy link" + expect(page).to have_content("Copied!") + expect(page).to have_css("[data-clipboard-copy-label]") + expect(page).to have_css("[data-clipboard-copy-message]") + expect(page).to have_css("[data-clipboard-content]") + end + end + + it "has a share link for each token" do + urls = share_tokens.map(&:url) + within ".share_tokens tbody tr", text: last_token.token do + share_window = window_opened_by { click_on "Preview" } + + within_window share_window do + expect(urls).to include(page.current_url) + end + end + end + + it "has a share button that opens the share url for the resource" do + within ".share_tokens tbody tr", text: last_token.token do + share_window = window_opened_by { click_on "Preview", wait: 2 } + + within_window share_window do + expect(current_url).to include(last_token.url) + end + end + end + + it "can delete tokens" do + within ".share_tokens tbody tr", text: last_token.token do + accept_confirm { click_on "Delete" } + end + + expect(page).to have_admin_callout("Access link successfully destroyed") + expect(page).to have_css("tbody tr", count: 2) + end + end + + context "when there are many pages" do + let!(:share_tokens) { create_list(:share_token, 26, :with_token, token_for: resource, organization:) } + + before do + visit_share_tokens_page + end + + it "displays pagination" do + expect(page).to have_css("tbody tr", count: 25) + within '[aria-label="Pagination"]' do + click_on "Next" + end + expect(page).to have_css("tbody tr", count: 1) + end + end +end + +shared_examples "manage component share tokens" do + let!(:components_path) { participatory_space_engine.components_path(participatory_space) } + let!(:component) { create(:component, participatory_space:, published_at: nil) } + let(:resource) { component } + let(:resource_name) { translated(component.name) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + end + + def visit_share_tokens_page + visit components_path + within ".table-list" do + click_on "Access links" + end + end + + it_behaves_like "manage resource share tokens" +end + +shared_examples "manage participatory space share tokens" do + let(:resource) { participatory_space } + let(:resource_name) { translated(resource.title) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + end + + def visit_share_tokens_page + visit participatory_spaces_path + click_on "Access links" + end + + it_behaves_like "manage resource share tokens" +end diff --git a/decidim-core/lib/decidim/core/test/shared_examples/preview_component_with_share_token_examples.rb b/decidim-core/lib/decidim/core/test/shared_examples/preview_component_with_share_token_examples.rb deleted file mode 100644 index f4788daf7dd6..000000000000 --- a/decidim-core/lib/decidim/core/test/shared_examples/preview_component_with_share_token_examples.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require "spec_helper" - -shared_examples_for "preview component with share_token" do - context "when component is unpublished" do - before do - component.unpublish! - end - - context "when no share_token is provided" do - before do - visit_component - end - - it "does not allow visiting component" do - expect(page).to have_content "You are not authorized" - expect(page).to have_no_current_path(main_component_path(component), ignore_query: true) - end - end - - context "when a share_token is provided" do - let(:share_token) { create(:share_token, token_for: component) } - let(:params) { { share_token: share_token.token } } - - before do - uri = URI(main_component_path(component)) - uri.query = URI.encode_www_form(params.to_a) - visit uri - end - - context "when a valid share_token is provided" do - it "allows visiting component" do - expect(page).to have_no_content "You are not authorized" - expect(page).to have_current_path(main_component_path(component), ignore_query: true) - end - end - - context "when an invalid share_token is provided" do - let(:share_token) { create(:share_token, :expired, token_for: component) } - - it "does not allow visiting component" do - expect(page).to have_content "You are not authorized" - expect(page).to have_no_current_path(main_component_path(component), ignore_query: true) - end - end - end - end -end diff --git a/decidim-core/lib/decidim/core/test/shared_examples/preview_with_share_token_examples.rb b/decidim-core/lib/decidim/core/test/shared_examples/preview_with_share_token_examples.rb new file mode 100644 index 000000000000..b27c5e2113fc --- /dev/null +++ b/decidim-core/lib/decidim/core/test/shared_examples/preview_with_share_token_examples.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +shared_examples "visit unpublished resource with a share token" do + context "when no share_token is provided" do + before do + visit_resource_page + end + + it "does not allow visiting resource" do + expect(page).to have_content "You are not authorized" + expect(page).to have_no_current_path(resource_path, ignore_query: true) + end + end + + context "when a share_token is provided" do + let(:share_token) { create(:share_token, :with_token, token_for: resource) } + let(:params) { { share_token: share_token.token } } + + before do + uri = URI(resource_path) + uri.query = URI.encode_www_form(params.to_a) + visit uri + end + + context "when a valid share_token is provided" do + it "allows visiting the resource" do + expect(page).to have_no_content "You are not authorized" + expect(current_url).to include(params[:share_token]) + expect(page).to have_current_path(resource_path, ignore_query: true) + + # repeat visit without the token in the params to check for the session + visit resource_path + expect(page).to have_no_content "You are not authorized" + expect(current_url).not_to include(params[:share_token]) + expect(page).to have_current_path(resource_path, ignore_query: true) + end + end + + context "when an invalid share_token is provided" do + let(:share_token) { create(:share_token, :with_token, :expired, token_for: resource) } + + it "does not allow visiting resource" do + expect(page).to have_content "You are not authorized" + expect(page).to have_no_current_path(resource_path, ignore_query: true) + end + end + + context "when the token requires the user to be registered" do + let(:share_token) { create(:share_token, :with_token, token_for: resource, registered_only: true) } + + it "does not allow visiting resource" do + expect(page).to have_content "You are not authorized" + expect(page).to have_no_current_path(resource_path, ignore_query: true) + end + + context "when a user is logged" do + let(:user) { create(:user, :confirmed, organization:) } + + it "allows visiting resource" do + login_as user, scope: :user + uri = URI(resource_path) + uri.query = URI.encode_www_form(params.to_a) + visit uri + expect(page).to have_no_content "You are not authorized" + expect(page).to have_current_path(resource_path, ignore_query: true) + end + end + end + end +end + +shared_examples "preview component with a share_token" do + let!(:component) { create(:component, manifest_name:, participatory_space:, published_at: nil) } + let(:resource) { component } + let(:resource_path) { main_component_path(component) } + + def visit_resource_page + visit_component + end + + it_behaves_like "visit unpublished resource with a share token" +end + +shared_examples "preview participatory space with a share_token" do + let(:resource) { participatory_space } + + before do + switch_to_host(organization.host) + end + + def visit_resource_page + visit resource_path + end + + it_behaves_like "visit unpublished resource with a share token" +end diff --git a/decidim-core/spec/models/decidim/share_token_spec.rb b/decidim-core/spec/models/decidim/share_token_spec.rb index 0a1ba0cb0739..1bab12c94a91 100644 --- a/decidim-core/spec/models/decidim/share_token_spec.rb +++ b/decidim-core/spec/models/decidim/share_token_spec.rb @@ -11,12 +11,14 @@ module Decidim let(:attributes) do { + token:, token_for:, user:, organization: } end + let(:token) { "SOME-TOKEN" } let(:user) { create(:user) } let(:token_for) { create(:component) } let(:organization) { token_for.organization } @@ -42,15 +44,84 @@ module Decidim it { is_expected.not_to be_valid } end + + context "when token is not present" do + # FactoryBot does not run the after_initializer block when building if token is defined + let(:share_token) { build(:share_token, token_for:, organization:) } + + it { is_expected.to be_valid } + + it "generates a token" do + expect(subject.token).to be_present + end + end + + context "when token is already taken" do + let(:token) { "taken" } + + before do + create(:share_token, token:, token_for:, organization:) + end + + it { is_expected.not_to be_valid } + end + + context "when token is already taken by another component" do + let(:token) { "taken" } + + before do + create(:share_token, token:, organization:) + end + + it { is_expected.to be_valid } + end + + context "when token has strange characters" do + let(:token) { "bon cop de falç" } + + it { is_expected.to be_invalid } + end end describe "defaults" do + let(:share_token) { build(:share_token, token_for:, organization:) } + it "generates an alphanumeric 64-character token string" do expect(subject.token).to match(/^[a-zA-Z0-9]{64}$/) end - it "sets expires_at attribute to one day from current time" do - expect(subject.expires_at).to be_within(1.second).of 1.day.from_now + it "sets expires_at attribute to never expire" do + expect(subject.expires_at).to be_nil + end + end + + describe "participatory space and components" do + let(:space) { token_for.participatory_space } + let(:component) { token_for } + + it "returns participatory space and component" do + expect(subject.participatory_space).to eq(space) + expect(subject.component).to eq(component) + end + + context "when token is for a participatory space" do + let(:space) { create(:participatory_process) } + let(:token_for) { space } + + it "returns the participatory space as the component" do + expect(subject.participatory_space).to eq(space) + expect(subject.component).to be_nil + end + end + + context "when resource does not respond to participatory_space" do + let(:organization) { create(:organization) } + let(:token_for) { organization } + + it "returns the component" do + expect(subject.participatory_space).to be_nil + expect(subject.component).to be_nil + end end end @@ -94,7 +165,7 @@ module Decidim describe "#expired?" do context "when share_token has not expired" do it "returns true" do - expect(subject.expired?).to be false + expect(subject.expired?).to be_nil end end diff --git a/decidim-debates/spec/system/preview_debates_with_share_token_spec.rb b/decidim-debates/spec/system/preview_debates_with_share_token_spec.rb index a6785e2c6ea2..7e9c1ff8a1d6 100644 --- a/decidim-debates/spec/system/preview_debates_with_share_token_spec.rb +++ b/decidim-debates/spec/system/preview_debates_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview debates with share token" do +describe "preview debates with a share token" do let(:manifest_name) { "debates" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-initiatives/app/controllers/decidim/initiatives/admin/component_share_tokens_controller.rb b/decidim-initiatives/app/controllers/decidim/initiatives/admin/component_share_tokens_controller.rb new file mode 100644 index 000000000000..78872533a94e --- /dev/null +++ b/decidim-initiatives/app/controllers/decidim/initiatives/admin/component_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Initiatives + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an initiative. + class ComponentShareTokensController < Decidim::Admin::ShareTokensController + include InitiativeAdmin + + def resource + @resource ||= current_participatory_space.components.find(params[:component_id]) + end + end + end + end +end diff --git a/decidim-initiatives/app/controllers/decidim/initiatives/admin/initiative_share_tokens_controller.rb b/decidim-initiatives/app/controllers/decidim/initiatives/admin/initiative_share_tokens_controller.rb new file mode 100644 index 000000000000..47de71cfa11d --- /dev/null +++ b/decidim-initiatives/app/controllers/decidim/initiatives/admin/initiative_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module Initiatives + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an initiative. + class InitiativeShareTokensController < Decidim::Admin::ShareTokensController + include InitiativeAdmin + + def resource + current_initiative + end + end + end + end +end diff --git a/decidim-initiatives/app/models/decidim/initiative.rb b/decidim-initiatives/app/models/decidim/initiative.rb index a98b15f2b491..da3b246e607c 100644 --- a/decidim-initiatives/app/models/decidim/initiative.rb +++ b/decidim-initiatives/app/models/decidim/initiative.rb @@ -24,6 +24,7 @@ class Initiative < ApplicationRecord include Decidim::HasResourcePermission include Decidim::HasArea include Decidim::FilterableResource + include Decidim::ShareableWithToken translatable_fields :title, :description, :answer @@ -457,6 +458,10 @@ def user_allowed_to_comment?(user) ActionAuthorizer.new(user, "comment", self, nil).authorize.ok? end + def shareable_url(share_token) + EngineRouter.main_proxy(self).initiative_url(self, share_token: share_token.token) + end + def self.ransack(params = {}, options = {}) Initiatives::InitiativeSearch.new(self, params, options) end diff --git a/decidim-initiatives/app/permissions/decidim/initiatives/admin/permissions.rb b/decidim-initiatives/app/permissions/decidim/initiatives/admin/permissions.rb index 7644684b5d62..a5c8cd22c7f5 100644 --- a/decidim-initiatives/app/permissions/decidim/initiatives/admin/permissions.rb +++ b/decidim-initiatives/app/permissions/decidim/initiatives/admin/permissions.rb @@ -39,6 +39,7 @@ def permissions initiative_export_action? initiatives_settings_action? moderator_action? + share_tokens_action? allow! if permission_action.subject == :attachment permission_action @@ -179,6 +180,12 @@ def moderator_action? allow! end + def share_tokens_action? + return unless permission_action.subject == :share_tokens + + allow! + end + def read_initiative_list_action? return unless permission_action.subject == :initiative && permission_action.action == :list diff --git a/decidim-initiatives/app/permissions/decidim/initiatives/permissions.rb b/decidim-initiatives/app/permissions/decidim/initiatives/permissions.rb index e14ef4363ed3..970e2e1bfbdf 100644 --- a/decidim-initiatives/app/permissions/decidim/initiatives/permissions.rb +++ b/decidim-initiatives/app/permissions/decidim/initiatives/permissions.rb @@ -52,6 +52,7 @@ def read_public_initiative? permission_action.action == :read return allow! if initiative.open? || initiative.rejected? || initiative.accepted? + return allow! if user_can_preview_space? return allow! if user && authorship_or_admin? disallow! @@ -181,6 +182,12 @@ def can_user_support?(initiative) ) end + def user_can_preview_space? + context[:share_token].present? && Decidim::ShareToken.use!(token_for: initiative, token: context[:share_token], user:) + rescue ActiveRecord::RecordNotFound, StandardError + nil + end + def initiative_committee_action? return unless permission_action.subject == :initiative_committee_member diff --git a/decidim-initiatives/app/views/decidim/initiatives/admin/initiatives/index.html.erb b/decidim-initiatives/app/views/decidim/initiatives/admin/initiatives/index.html.erb index 0e68825a11a6..69e2b8d9a626 100644 --- a/decidim-initiatives/app/views/decidim/initiatives/admin/initiatives/index.html.erb +++ b/decidim-initiatives/app/views/decidim/initiatives/admin/initiatives/index.html.erb @@ -45,6 +45,13 @@ <%= l initiative.created_at, format: :short %> <%= initiative.published_at? ? l(initiative.published_at, format: :short) : "" %> + + <% if allowed_to? :read, :share_tokens, current_participatory_space: initiative %> + <%= icon_link_to "share-line", decidim_admin_initiatives.initiative_share_tokens_path(initiative), t("actions.share_tokens", scope: "decidim.admin"), class: "action-icon--new" %> + <% else %> + + <% end %> + <% if allowed_to? :edit, :initiative, initiative: initiative %> <%= icon_link_to "pencil-line", decidim_admin_initiatives.edit_initiative_path(initiative.to_param), diff --git a/decidim-initiatives/lib/decidim/initiatives/admin_engine.rb b/decidim-initiatives/lib/decidim/initiatives/admin_engine.rb index 14b388d96c11..d0148422fe7a 100644 --- a/decidim-initiatives/lib/decidim/initiatives/admin_engine.rb +++ b/decidim-initiatives/lib/decidim/initiatives/admin_engine.rb @@ -64,6 +64,7 @@ class AdminEngine < ::Rails::Engine get :share put :hide end + resources :component_share_tokens, except: [:show], path: "share_tokens", as: "share_tokens" resources :exports, only: :create end @@ -75,6 +76,8 @@ class AdminEngine < ::Rails::Engine end resources :reports, controller: "moderations/reports", only: [:index, :show] end + + resources :initiative_share_tokens, except: [:show], path: "share_tokens" end scope "/initiatives/:initiative_slug/components/:component_id/manage" do diff --git a/decidim-initiatives/lib/decidim/initiatives/menu.rb b/decidim-initiatives/lib/decidim/initiatives/menu.rb index d11c73efc938..8ef0c64f090c 100644 --- a/decidim-initiatives/lib/decidim/initiatives/menu.rb +++ b/decidim-initiatives/lib/decidim/initiatives/menu.rb @@ -66,6 +66,7 @@ def self.register_admin_initiatives_components_menu! active: is_active_link?(manage_component_path(component)) || is_active_link?(decidim_admin_initiatives.edit_component_path(current_participatory_space, component)) || is_active_link?(decidim_admin_initiatives.edit_component_permissions_path(current_participatory_space, component)) || + is_active_link?(decidim_admin_initiatives.component_share_tokens_path(current_participatory_space, component)) || participatory_space_active_link?(component), if: component.manifest.admin_engine # && user_role_config.component_is_accessible?(component.manifest_name) end @@ -106,6 +107,13 @@ def self.register_admin_initiative_menu! decidim_admin_initiatives.moderations_path(current_participatory_space), icon_name: "flag-line", if: allowed_to?(:read, :moderation) + + menu.add_item :initiatives_share_tokens, + I18n.t("menu.share_tokens", scope: "decidim.admin"), + decidim_admin_initiatives.initiative_share_tokens_path(current_participatory_space), + active: is_active_link?(decidim_admin_initiatives.initiative_share_tokens_path(current_participatory_space)), + icon_name: "share-line", + if: allowed_to?(:read, :share_tokens, current_participatory_space:) end end diff --git a/decidim-initiatives/spec/system/admin/admin_manages_initiative_component_share_tokens_spec.rb b/decidim-initiatives/spec/system/admin/admin_manages_initiative_component_share_tokens_spec.rb new file mode 100644 index 000000000000..430c509f2d8b --- /dev/null +++ b/decidim-initiatives/spec/system/admin/admin_manages_initiative_component_share_tokens_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages initiative component share tokens" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:participatory_space) do + create(:initiative, organization:) + end + + it_behaves_like "manage component share tokens" do + let(:participatory_space_engine) { decidim_admin_initiatives } + end +end diff --git a/decidim-initiatives/spec/system/admin/admin_manages_initiative_components_spec.rb b/decidim-initiatives/spec/system/admin/admin_manages_initiative_components_spec.rb index 8e2778924f77..559bbe6a37a3 100644 --- a/decidim-initiatives/spec/system/admin/admin_manages_initiative_components_spec.rb +++ b/decidim-initiatives/spec/system/admin/admin_manages_initiative_components_spec.rb @@ -199,8 +199,6 @@ expect(page).to have_css(".action-icon--unpublish") end end - - it_behaves_like "manage component share tokens" end context "when the component is published" do diff --git a/decidim-initiatives/spec/system/admin/admin_manages_initiative_share_tokens_spec.rb b/decidim-initiatives/spec/system/admin/admin_manages_initiative_share_tokens_spec.rb new file mode 100644 index 000000000000..0c043121fe04 --- /dev/null +++ b/decidim-initiatives/spec/system/admin/admin_manages_initiative_share_tokens_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages initiative share tokens" do + let(:organization) { create(:organization) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + let!(:participatory_space) do + create(:initiative, organization:) + end + + it_behaves_like "manage participatory space share tokens" do + let(:participatory_space_path) { decidim_admin_initiatives.edit_initiative_path(participatory_space) } + let(:participatory_spaces_path) { decidim_admin_initiatives.initiatives_path } + end +end diff --git a/decidim-initiatives/spec/system/preview_initiative_with_share_token_spec.rb b/decidim-initiatives/spec/system/preview_initiative_with_share_token_spec.rb new file mode 100644 index 000000000000..bf19aec9acd2 --- /dev/null +++ b/decidim-initiatives/spec/system/preview_initiative_with_share_token_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Preview initiative with share token" do + let(:organization) { create(:organization) } + let!(:participatory_space) { create(:initiative, :created, organization:) } + let(:resource_path) { decidim_initiatives.initiative_path(participatory_space) } + + it_behaves_like "preview participatory space with a share_token" +end diff --git a/decidim-meetings/spec/system/preview_meetings_with_share_token_spec.rb b/decidim-meetings/spec/system/preview_meetings_with_share_token_spec.rb index 844f7892e22e..5e6bacd87388 100644 --- a/decidim-meetings/spec/system/preview_meetings_with_share_token_spec.rb +++ b/decidim-meetings/spec/system/preview_meetings_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview meetings with share token" do +describe "preview meetings with a share token" do let(:manifest_name) { "meetings" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-pages/spec/system/preview_pages_with_share_token_spec.rb b/decidim-pages/spec/system/preview_pages_with_share_token_spec.rb index 657244defaed..273ee7869d23 100644 --- a/decidim-pages/spec/system/preview_pages_with_share_token_spec.rb +++ b/decidim-pages/spec/system/preview_pages_with_share_token_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe "Preview pages with share token" do +describe "preview pages with a share token" do let(:manifest_name) { "pages" } let(:body) do @@ -16,5 +16,5 @@ let!(:page_component) { create(:page, component:, body:) } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/component_share_tokens_controller.rb b/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/component_share_tokens_controller.rb new file mode 100644 index 000000000000..14442d7b4b77 --- /dev/null +++ b/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/component_share_tokens_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Decidim + module ParticipatoryProcesses + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an process. + class ComponentShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::ParticipatoryProcessAdmin + + def resource + @resource ||= current_participatory_space.components.find(params[:component_id]) + end + end + end + end +end diff --git a/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/participatory_process_share_tokens_controller.rb b/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/participatory_process_share_tokens_controller.rb new file mode 100644 index 000000000000..c2ede3047a5a --- /dev/null +++ b/decidim-participatory_processes/app/controllers/decidim/participatory_processes/admin/participatory_process_share_tokens_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Decidim + module ParticipatoryProcesses + module Admin + # This controller allows admins to manage moderations in a participatory process. + class ParticipatoryProcessShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::ParticipatoryProcessAdmin + + def resource + current_participatory_process + end + end + end + end +end diff --git a/decidim-participatory_processes/app/models/decidim/participatory_process.rb b/decidim-participatory_processes/app/models/decidim/participatory_process.rb index 8c81a0836944..acb1c4001e46 100644 --- a/decidim-participatory_processes/app/models/decidim/participatory_process.rb +++ b/decidim-participatory_processes/app/models/decidim/participatory_process.rb @@ -24,6 +24,7 @@ class ParticipatoryProcess < ApplicationRecord include Decidim::TranslatableResource include Decidim::HasArea include Decidim::FilterableResource + include Decidim::ShareableWithToken translatable_fields :title, :subtitle, :short_description, :description, :developer_group, :meta_scope, :local_area, :target, :participatory_scope, :participatory_structure, :announcement @@ -201,6 +202,10 @@ def attachment_context :admin end + def shareable_url(share_token) + EngineRouter.main_proxy(self).participatory_process_url(self, share_token: share_token.token) + end + # Allow ransacker to search for a key in a hstore column (`title`.`en`) ransacker_i18n :title diff --git a/decidim-participatory_processes/app/permissions/decidim/participatory_processes/permissions.rb b/decidim-participatory_processes/app/permissions/decidim/participatory_processes/permissions.rb index ba4ae1589bb7..103d2f435fe0 100644 --- a/decidim-participatory_processes/app/permissions/decidim/participatory_processes/permissions.rb +++ b/decidim-participatory_processes/app/permissions/decidim/participatory_processes/permissions.rb @@ -118,6 +118,7 @@ def public_read_process_action? return disallow! unless can_view_private_space? return allow! if user&.admin? return allow! if process.published? + return allow! if user_can_preview_space? toggle_allow(can_manage_process?) end @@ -238,6 +239,7 @@ def process_admin_action? :process_step, :process_user_role, :export_space, + :share_tokens, :import ].include?(permission_action.subject) allow! if is_allowed @@ -257,11 +259,18 @@ def org_admin_action? :process_step, :process_user_role, :export_space, + :share_tokens, :import ].include?(permission_action.subject) allow! if is_allowed end + def user_can_preview_space? + context[:share_token].present? && Decidim::ShareToken.use!(token_for: process, token: context[:share_token], user:) + rescue ActiveRecord::RecordNotFound, StandardError + nil + end + def taxonomy_filter_action? return unless permission_action.subject == :taxonomy_filter return disallow! unless user.admin? diff --git a/decidim-participatory_processes/app/views/decidim/participatory_processes/admin/participatory_processes/index.html.erb b/decidim-participatory_processes/app/views/decidim/participatory_processes/admin/participatory_processes/index.html.erb index 829ded669fe0..84cfd004e776 100644 --- a/decidim-participatory_processes/app/views/decidim/participatory_processes/admin/participatory_processes/index.html.erb +++ b/decidim-participatory_processes/app/views/decidim/participatory_processes/admin/participatory_processes/index.html.erb @@ -58,6 +58,11 @@ <% end %> + <% if allowed_to? :read, :share_tokens, current_participatory_space: process %> + <%= icon_link_to "share-line", decidim_admin_participatory_processes.participatory_process_share_tokens_path(process), t("actions.share_tokens", scope: "decidim.admin"), class: "action-icon--new" %> + <% else %> + + <% end %> <% if allowed_to? :update, :process, process: process %> <%= icon_link_to "pencil-line", edit_participatory_process_path(process), t("actions.configure", scope: "decidim.admin"), class: "action-icon--new" %> diff --git a/decidim-participatory_processes/lib/decidim/participatory_processes/admin_engine.rb b/decidim-participatory_processes/lib/decidim/participatory_processes/admin_engine.rb index f7b4a97b6a0b..5873af194be3 100644 --- a/decidim-participatory_processes/lib/decidim/participatory_processes/admin_engine.rb +++ b/decidim-participatory_processes/lib/decidim/participatory_processes/admin_engine.rb @@ -65,6 +65,7 @@ class AdminEngine < ::Rails::Engine get :share put :hide end + resources :component_share_tokens, except: [:show], path: "share_tokens", as: "share_tokens" resources :exports, only: :create resources :imports, only: [:new, :create] do get :example, on: :collection @@ -91,6 +92,8 @@ class AdminEngine < ::Rails::Engine end end end + + resources :participatory_process_share_tokens, except: [:show], path: "share_tokens" end scope "/participatory_processes/:participatory_process_slug/components/:component_id/manage" do diff --git a/decidim-participatory_processes/lib/decidim/participatory_processes/menu.rb b/decidim-participatory_processes/lib/decidim/participatory_processes/menu.rb index d5ec86d3f5c5..295931459b12 100644 --- a/decidim-participatory_processes/lib/decidim/participatory_processes/menu.rb +++ b/decidim-participatory_processes/lib/decidim/participatory_processes/menu.rb @@ -101,6 +101,7 @@ def self.register_admin_participatory_process_components_menu! active: is_active_link?(manage_component_path(component)) || is_active_link?(decidim_admin_participatory_processes.edit_component_path(current_participatory_space, component)) || is_active_link?(decidim_admin_participatory_processes.edit_component_permissions_path(current_participatory_space, component)) || + is_active_link?(decidim_admin_participatory_processes.component_share_tokens_path(current_participatory_space, component)) || participatory_space_active_link?(component), if: component.manifest.admin_engine && user_role_config.component_is_accessible?(component.manifest_name) end @@ -174,6 +175,13 @@ def self.register_admin_participatory_process_menu! active: is_active_link?(decidim_admin_participatory_processes.moderations_path(current_participatory_space)), icon_name: "flag-line", if: allowed_to?(:read, :moderation, current_participatory_space:) + + menu.add_item :participatory_process_share_tokens, + I18n.t("menu.share_tokens", scope: "decidim.admin"), + decidim_admin_participatory_processes.participatory_process_share_tokens_path(current_participatory_space), + active: is_active_link?(decidim_admin_participatory_processes.participatory_process_share_tokens_path(current_participatory_space)), + icon_name: "share-line", + if: allowed_to?(:read, :share_tokens, current_participatory_space:) end end diff --git a/decidim-participatory_processes/spec/shared/manage_process_components_examples.rb b/decidim-participatory_processes/spec/shared/manage_process_components_examples.rb index d2a9bcc3b8ff..c6e3f6e7db16 100644 --- a/decidim-participatory_processes/spec/shared/manage_process_components_examples.rb +++ b/decidim-participatory_processes/spec/shared/manage_process_components_examples.rb @@ -26,8 +26,6 @@ find(".dummy").click end - expect(page).to have_no_content("Share tokens") - within ".item__edit-form .new_component" do fill_in_i18n( :component_name, @@ -300,14 +298,6 @@ end context "when the component is unpublished" do - it "shows the share tokens section" do - within ".component-#{component.id}" do - click_on "Configure" - end - - expect(page).to have_content("Share tokens") - end - it "publishes the component" do within ".component-#{component.id}" do click_on "Publish" @@ -337,21 +327,11 @@ } )) end - - it_behaves_like "manage component share tokens" end context "when the component is published" do let(:published_at) { Time.current } - it "does not show the share tokens section" do - within ".component-#{component.id}" do - click_on "Configure" - end - - expect(page).to have_no_content("Share tokens") - end - it "hides the component from the menu" do within ".component-#{component.id}" do click_on "Hide" diff --git a/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_component_share_tokens_spec.rb b/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_component_share_tokens_spec.rb new file mode 100644 index 000000000000..dd663d92b669 --- /dev/null +++ b/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_component_share_tokens_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages participatory process component share tokens" do + include_context "when admin administrating a participatory process" + + it_behaves_like "manage component share tokens" do + let(:participatory_space) { participatory_process } + let(:participatory_space_engine) { decidim_admin_participatory_processes } + end +end diff --git a/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_share_tokens_spec.rb b/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_share_tokens_spec.rb new file mode 100644 index 000000000000..acd0a914561e --- /dev/null +++ b/decidim-participatory_processes/spec/system/admin/admin_manages_participatory_process_share_tokens_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages participatory process share tokens" do + include_context "when admin administrating a participatory process" + let(:participatory_space) { participatory_process } + let(:participatory_space_path) { decidim_admin_participatory_processes.edit_participatory_process_path(participatory_process) } + let(:participatory_spaces_path) { decidim_admin_participatory_processes.participatory_processes_path } + + it_behaves_like "manage participatory space share tokens" + + context "when the user is a process admin" do + let(:user) { create(:user, :confirmed, :admin_terms_accepted, organization:) } + let!(:role) { create(:participatory_process_user_role, user:, participatory_process:, role: :admin) } + + it_behaves_like "manage participatory space share tokens" + end +end diff --git a/decidim-participatory_processes/spec/system/preview_participatory_process_with_share_token_spec.rb b/decidim-participatory_processes/spec/system/preview_participatory_process_with_share_token_spec.rb new file mode 100644 index 000000000000..7dadba5d57f2 --- /dev/null +++ b/decidim-participatory_processes/spec/system/preview_participatory_process_with_share_token_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Preview participatory process with share token" do + let(:organization) { create(:organization) } + let!(:participatory_space) { create(:participatory_process, organization:, published_at: nil) } + let(:resource_path) { decidim_participatory_processes.participatory_process_path(participatory_space) } + + it_behaves_like "preview participatory space with a share_token" +end diff --git a/decidim-proposals/spec/system/preview_proposals_with_share_token_spec.rb b/decidim-proposals/spec/system/preview_proposals_with_share_token_spec.rb index 6b9292820307..fc36958a99eb 100644 --- a/decidim-proposals/spec/system/preview_proposals_with_share_token_spec.rb +++ b/decidim-proposals/spec/system/preview_proposals_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview proposals with share token" do +describe "preview proposals with a share token" do let(:manifest_name) { "proposals" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-sortitions/spec/system/decidim/sortitions/preview_sortitions_with_share_token_spec.rb b/decidim-sortitions/spec/system/decidim/sortitions/preview_sortitions_with_share_token_spec.rb index a3cbcc00a7d1..cdf94b1a9d78 100644 --- a/decidim-sortitions/spec/system/decidim/sortitions/preview_sortitions_with_share_token_spec.rb +++ b/decidim-sortitions/spec/system/decidim/sortitions/preview_sortitions_with_share_token_spec.rb @@ -2,9 +2,9 @@ require "spec_helper" -describe "Preview sortitions with share token" do +describe "preview sortitions with a share token" do let(:manifest_name) { "sortitions" } include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" end diff --git a/decidim-surveys/app/views/decidim/surveys/admin/component/_actions.html.erb b/decidim-surveys/app/views/decidim/surveys/admin/component/_actions.html.erb index 3c9fec22c0f6..f1c87caab50a 100644 --- a/decidim-surveys/app/views/decidim/surveys/admin/component/_actions.html.erb +++ b/decidim-surveys/app/views/decidim/surveys/admin/component/_actions.html.erb @@ -4,11 +4,12 @@ <% end %> -<% if allowed_to? :share, :component, component: component %> - <%= icon_link_to "share-line", url_for(action: :share, id: component, controller: "components"), t("actions.share", scope: "decidim.admin"), target: :blank, class: "action-icon--share" %> +<% if component.manifest.admin_engine && allowed_to?(:share, :component, component: component) %> + <%= icon_link_to "share-line", component_share_tokens_path(component_id: component), t("actions.share", scope: "decidim.admin"), class: "action-icon--share" %> <% else %> <% end %> + <% if allowed_to? :update, :component, component: component %> <%= icon_link_to "settings-4-line", url_for(action: :edit, id: component, controller: "components"), t("actions.configure", scope: "decidim.admin"), class: "action-icon--configure" %> <% else %> @@ -17,7 +18,11 @@ <% if allowed_to?(:update, :component, component: component) %> <% if component.published? %> - <%= icon_link_to "close-circle-line", url_for(action: :unpublish, id: component, controller: "components"), t("actions.unpublish", scope: "decidim.admin"), class: "action-icon--unpublish", method: :put %> + <% if component.visible? %> + <%= icon_link_to "eye-close", url_for(action: :hide, id: component, controller: "components"), t("actions.menu_hidden", scope: "decidim.admin"), class: "action-icon--unpublish", method: :put %> + <% else %> + <%= icon_link_to "close-circle-line", url_for(action: :unpublish, id: component, controller: "components"), t("actions.unpublish", scope: "decidim.admin"), class: "action-icon--menu-hidden", method: :put %> + <% end %> <% else %> <%= icon_link_to "check-line", url_for(action: :publish, id: component, controller: "components"), t("actions.publish", scope: "decidim.admin"), class: "action-icon--publish", method: :put, data: { confirm: t(".answers_alert") } %> <% end %> diff --git a/decidim-surveys/spec/system/survey_spec.rb b/decidim-surveys/spec/system/survey_spec.rb index f38404f4f534..d845b4051e03 100644 --- a/decidim-surveys/spec/system/survey_spec.rb +++ b/decidim-surveys/spec/system/survey_spec.rb @@ -33,7 +33,7 @@ include_context "with a component" - it_behaves_like "preview component with share_token" + it_behaves_like "preview component with a share_token" context "when the survey does not allow answers" do it "does not allow answering the survey" do diff --git a/docs/modules/develop/pages/share_tokens.adoc b/docs/modules/develop/pages/share_tokens.adoc index 9c787a8afcde..741f0c32d73a 100644 --- a/docs/modules/develop/pages/share_tokens.adoc +++ b/docs/modules/develop/pages/share_tokens.adoc @@ -1,6 +1,6 @@ = Share tokens -Share tokens can be assigned to any model to provide a system to share unpublished resources with expirable and manageable tokens. +Share tokens can be assigned to any model to provide a system to share unpublished resources with expiration dates through the creation/destruction of tokens. A share token is created by a user with an expiration time, and can be added as a query param to access otherwise restricted locations. @@ -12,7 +12,7 @@ The model must `include Decidim::ShareableWithToken` and implement `shareable_ur ---- # Public: Public URL for your_resource with given share token as query parameter def shareable_url(share_token) - your_resource_public_path(self, share_token: share_token.token) + your_resource_public_url(self, share_token: share_token.token) end ---- @@ -31,27 +31,169 @@ return unless token.present? allow! if Decidim::ShareToken.use!(token_for: your_resource, token: token) ---- +Note that, if you are using a controller who is inheriting from `Decidim::ApplicationController`, you do not need to include the `:share_token` in the context when calling methods like `enforce_permission_to`, as it is already included in the `Decidim::NeedsPermissions` class through the method `store_share_token`. + == Manage tokens -Render the partial `decidim-admin/app/views/decidim/admin/share_tokens/_share_tokens.html.erb` inside a view, with: +By default, participatory spaces like process, assemblies, conferences and initiatives are configured to have share tokens, as well as the individual components that are included in them. Participatory spaces have a "Share tokens" tab in the admin view, where you can create new tokens, see the list of existing ones, and revoke them. +Tokens can also be managed in the components view similarly as other resources to give pre-access (with and action icon like permissions for instance). + +Tokens generated for a participatory space are valid for all the components included in it (regardless of their publication status), and tokens generated for a component are valid for that component only. + +== Implementation for participatory spaces + +In order to implement share tokens for a participatory spaces, you need to: + +=== 1. Routes + +Add the `share_tokens` CRUD routes in your `admin_engine.rb` file: + +[source,ruby] +---- +scope "/assemblies/:assembly_slug" do + ... + resources :assembly_share_tokens, except: [:show], path: "share_tokens" + ... +end +---- + +=== 2. Controller + +Add the controller for the participatory space, it only requires to inherit from `Decidim::Admin::ShareTokensController` and define the `resource` method to return the participatory space: + +[source,ruby] +---- +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an assembly. + class AssemblyShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::AssemblyAdmin + + def resource + current_assembly + end + end + end + end +end +---- + +=== 3. Menu entry + +Add the menu entry for the share tokens in the participatory space admin view. In Decidim we do this in the `menu.rb` file for each participatory space: + +[source,ruby] +---- +Decidim.menu :admin_assembly_menu do |menu| + ... + menu.add_item :assembly_share_tokens, + I18n.t("menu.share_tokens", scope: "decidim.admin"), + decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space), + active: is_active_link?(decidim_admin_assemblies.assembly_share_tokens_path(current_participatory_space)), + icon_name: "share-line", + if: allowed_to?(:read, :share_tokens, current_participatory_space:) + ... +end +---- + +=== 4. Model + +Ensure your participatory space model includes the `Decidim::ShareableWithToken` module and implements the `shareable_url` method: + +[source,ruby] +---- +module Decidim + class Assembly < ApplicationRecord + ... + include Decidim::ShareableWithToken + ... + def shareable_url(share_token) + EngineRouter.main_proxy(self).assembly_url(self, share_token: share_token.token) + end + ... + end +end +---- + +=== 5. Permissions + +Add the permissions logic to the participatory space controller in the `permissions.rb` file: + +For admin controllers: [source,ruby] ---- -locals: { share_tokens: your_share_tokens_variable } +allow! if permission_action.subject == :share_tokens +---- + +For frontend controllers: + +[source,ruby] +---- +token = context[:share_token] + +return unless token.present? + +allow! if Decidim::ShareToken.use!(token_for: current_assembly, token: token) ---- -to let admins see and manage tokens for that resource. +== Implementation for components -== Link to url with token +Components all inherit from `Decidim::Component`, so they already have the `Decidim::ShareableWithToken` module included. But you still need to do some steps: -Implement a `share` action (see below) in the resource controller (admin scope), redirecting to a url with a newly generated token, so you can call `share_my_resource_url`. +=== 1. Routes + +Add the `share_tokens` CRUD routes in your `admin_engine.rb` file: [source,ruby] ---- -def share - @your_resource = YourResource.find(params[:id]) # or whatever - share_token = @your_resource.share_tokens.create!(user: current_user, organization: current_organization) +scope "/assemblies/:assembly_slug" do + ... + resources :components do + ... + resources :component_share_tokens, except: [:show], path: "share_tokens", as: "share_tokens" + ... + end +end +---- + +=== 2. Controller + +Add the controller for the component, it only requires to inherit from `Decidim::Admin::ShareTokensController` and define the `resource` method to return the component: - redirect_to share_token.url +[source,ruby] +---- +# frozen_string_literal: true + +module Decidim + module Assemblies + module Admin + # This controller allows sharing unpublished things. + # It is targeted for customizations for sharing unpublished things that lives under + # an assembly. + class ComponentShareTokensController < Decidim::Admin::ShareTokensController + include Concerns::AssemblyAdmin + + def resource + @resource ||= current_participatory_space.components.find(params[:component_id]) + end + end + end + end end ---- + +=== 3. Permissions + +Similarly, add the same permissions logic to the component controller in the `permissions.rb` file as for participatory spaces. + + +== Other implementations + +You can implement share tokens for any other model by following the same steps as for participatory spaces and components. +In that case, however, you might have to override some methods from the `Decidim::Admin::ShareTokensController` to adapt them to your model (check the source code for details). \ No newline at end of file