From 47d9ec27fb30211d5b391bbc188cce9e5802f31d Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Tue, 4 Jun 2019 09:30:52 -0400 Subject: [PATCH] Allow users to belong to multiple orgs (#2583) * Allow user to have many orgs * Allow users to handle multi orgs in settings * Make rounded buttons inline * Add multi org function to dashboards * Fix merge conflicts * Fix mistake in merge conflict fix oops * Display the correct membership level * Fix accessibility issues * Display organizations for article editors * Handle submitting org id with preact editors * Make listings work with multiple organizations * Allow listings to have multiple orgs on create * Display the correct number of credits for each org * Move script tag to Webpack * Allow multi orgs for purchasing and viewing credits * Use OrganizationMembership as authorization check * Display multiple organizations for notifications * Allow dashboard to be viewable under multi-orgs * Remove unused method * Add multi-org functionality for article editors * Show pro dashboard buttons for member+ org levels * Leave the correct organization * Allow article API to change org id * Add left-out authorization method oops * Make nav buttons a bit more clear * Fix merge conflict * Fix adding org id for /api/articles and tests * Fix tests for org policy * Use proper logic for displaying org members * Update org actions with new authorization * Use correct org when creating a listing * Remove additional payment charge oops * Mark org notifications as read with authorization * Remove deprecated post_as_organization attribute * Use new org_admin syntax * Remove deprecated org logic for article create and update * Default all RSS posts to not belong to any org * Render org_member page for guest users * Update org policy spec to work with multi orgs * Use org_membership for org traits and move identity code * Use org_member trait * Update to work with multi-orgs * Validate article's org_id if param org_id is blank * Make a let variable * Remove unnecessary eager load for credits * Fix HTML structure and org logic for non-org users * Update credits spec for multi-org * Add test for failed payment when purchased by org * Lint listings_spec * Test that the listing was created under the user * Add tests for POST /listings multi-org * Use double quotes for classes * Fix /manage and a few other multi-org bugs * Fix test for multi org * Use correct method SQL exists? not Rails exist? * Fix reads spec for multi-org * Fix org_controller actions to work with multi org * Test only multi org and not old usage and fix leave_org * Fix org showing user profile img test for multi-org * Fix org logic for users with no orgs * Remove switch org functionality * Update tests and add hidden param for org id * Redirect to the specific organization * Test other org button actions * Use settings_notice instead of legacy notice and refactor * Fix weird extra end issue prob from merge conflicts * Test for with new flash key * Fix user_views_org tests for multi-org * Test for new flash message * Update snapshot with new a11y html * Move styling to stylesheet * Add site admins functionality * Move org_member? method in user model and refactor * Use unspent_credits_count for organizations * Add tests for /listings/new and minor bug fixes * Use .present? in case of empty array * Fix a lingering deprecated method * Use greater than 1 for random numbers * Add tests for counting spent and unspent credits --- .../stylesheets/classified_listings.scss | 3 + app/assets/stylesheets/credits.scss | 4 + app/assets/stylesheets/dashboard.scss | 2 +- .../stylesheets/preact/article-form.scss | 46 +------- app/assets/stylesheets/settings.scss | 42 +++++++ .../api/v0/analytics_controller.rb | 10 +- app/controllers/api/v0/articles_controller.rb | 13 ++- app/controllers/articles_controller.rb | 35 +++--- .../classified_listings_controller.rb | 12 +- app/controllers/credits_controller.rb | 23 ++-- app/controllers/dashboards_controller.rb | 19 +-- .../notifications/reads_controller.rb | 12 +- app/controllers/notifications_controller.rb | 4 +- app/controllers/organizations_controller.rb | 23 ++-- app/controllers/users_controller.rb | 108 ++++++++++-------- .../__snapshots__/articleForm.test.jsx.snap | 5 + app/javascript/article-form/articleForm.jsx | 35 +++--- .../article-form/elements/orgSettings.jsx | 37 +++--- .../listings/elements/orgSettings.jsx | 35 ++++++ .../listings/elements/organizationField.jsx | 0 app/javascript/listings/listingForm.jsx | 28 ++++- app/javascript/packs/articleForm.jsx | 4 +- app/javascript/packs/orgCreditsSelector.js | 15 +++ app/models/article.rb | 5 +- app/models/classified_listing.rb | 2 +- app/models/credit.rb | 16 ++- app/models/organization.rb | 4 +- app/models/organization_membership.rb | 3 + app/models/role.rb | 1 - app/models/user.rb | 19 ++- app/policies/article_policy.rb | 2 +- app/policies/classified_listing_policy.rb | 4 + app/policies/organization_policy.rb | 12 +- app/policies/user_policy.rb | 18 +-- app/services/article_creation_service.rb | 5 - app/services/articles/updater.rb | 3 - app/services/rss_reader.rb | 2 +- app/views/articles/_v2_form.html.erb | 13 +-- app/views/articles/edit.html.erb | 2 +- app/views/articles/manage.html.erb | 2 +- app/views/articles/new.html.erb | 2 +- app/views/classified_listings/_form.html.erb | 42 ++++--- app/views/credits/index.html.erb | 19 ++- app/views/credits/new.html.erb | 35 +++--- .../dashboards/_dashboard_article.html.erb | 12 +- app/views/dashboards/followers.html.erb | 17 +-- app/views/dashboards/pro.html.erb | 20 ++-- app/views/dashboards/show.html.erb | 21 ++-- app/views/notifications/index.html.erb | 38 +++--- app/views/users/_org_admin.html.erb | 33 +++--- app/views/users/_org_guest.html.erb | 1 + app/views/users/_org_member.html.erb | 4 +- app/views/users/_organization.html.erb | 6 +- app/views/users/edit.html.erb | 21 +++- config/routes.rb | 7 +- ...118_add_articles_count_to_organizations.rb | 5 + db/schema.rb | 3 +- spec/factories/credits.rb | 3 + spec/factories/users.rb | 25 ++-- spec/models/classified_listing_spec.rb | 3 +- spec/models/credit_spec.rb | 42 +++++-- spec/models/notification_spec.rb | 4 +- spec/models/organization_membership_spec.rb | 2 +- spec/models/user_spec.rb | 4 +- spec/policies/organization_policy_spec.rb | 16 ++- spec/policies/user_policy_spec.rb | 32 +----- spec/requests/api/v0/articles_spec.rb | 8 +- spec/requests/articles_create_spec.rb | 8 +- spec/requests/articles_update_spec.rb | 21 ++-- spec/requests/classified_listings_spec.rb | 74 ++++++++++-- spec/requests/credits_spec.rb | 42 ++++--- spec/requests/dashboard_spec.rb | 13 ++- spec/requests/listings_spec.rb | 68 +++++++---- spec/requests/notifications/reads_spec.rb | 8 +- spec/requests/organizations_update_spec.rb | 19 +-- spec/requests/user_organization_spec.rb | 89 +++++++++------ spec/requests/user_profile_spec.rb | 2 +- spec/requests/user_settings_spec.rb | 6 +- .../user_updates_org_settings_spec.rb | 27 ++++- .../user_views_an_organization_spec.rb | 4 +- .../user/user_edits_integrations_spec.rb | 2 +- 81 files changed, 882 insertions(+), 554 deletions(-) create mode 100644 app/javascript/listings/elements/orgSettings.jsx delete mode 100644 app/javascript/listings/elements/organizationField.jsx create mode 100644 app/javascript/packs/orgCreditsSelector.js create mode 100644 app/views/users/_org_guest.html.erb create mode 100644 db/migrate/20190521190118_add_articles_count_to_organizations.rb diff --git a/app/assets/stylesheets/classified_listings.scss b/app/assets/stylesheets/classified_listings.scss index 132689b42..e05ac5439 100644 --- a/app/assets/stylesheets/classified_listings.scss +++ b/app/assets/stylesheets/classified_listings.scss @@ -284,6 +284,9 @@ .listings-current-credits-inner { display: inline-block; margin: 20px; + select { + width: initial; + } } a { text-decoration: underline; diff --git a/app/assets/stylesheets/credits.scss b/app/assets/stylesheets/credits.scss index 8a5ea89df..e19615fc2 100644 --- a/app/assets/stylesheets/credits.scss +++ b/app/assets/stylesheets/credits.scss @@ -258,6 +258,10 @@ font-size: 1.8em; font-weight: bold; padding: 30px 0px 50px; + .org-credits-select { + vertical-align: middle; + font-size: initial; + } } a { color: white; diff --git a/app/assets/stylesheets/dashboard.scss b/app/assets/stylesheets/dashboard.scss index 352feeaea..362c41ddc 100644 --- a/app/assets/stylesheets/dashboard.scss +++ b/app/assets/stylesheets/dashboard.scss @@ -33,7 +33,7 @@ padding: 4px 20px; border-radius: 100px; margin-right: 5px; - display: block; + display: inline-block; width: fit-content; margin-bottom: 10px; color: $black; diff --git a/app/assets/stylesheets/preact/article-form.scss b/app/assets/stylesheets/preact/article-form.scss index 5ee16c673..39f00ca02 100644 --- a/app/assets/stylesheets/preact/article-form.scss +++ b/app/assets/stylesheets/preact/article-form.scss @@ -95,53 +95,13 @@ padding-left: 0px; margin: 20px 2px 0px; font-size: 16px; - cursor: pointer; - user-select: none; - img { - height: 26px; - width: 26px; - border-radius: 50px; - vertical-align: -7px; - margin-right: 4px; - } - button { - background: transparent; - border-radius: 3px; - font-size: 0.8em; - font-family: $helvetica-condensed; - width: 70px; - border-radius: 100px; - padding: 1px 0px; - margin-left: 10px; - border: var(--theme-container-border, 1px solid white); - &:focus { - outline: 0; - border: 1px solid $green; - } - &.no { - background: $light-gray; - background: var(--theme-background, $light-gray); - color: $medium-gray; - color: var(--theme-secondary-color, $medium-gray); - - } - &.yes { - background: darken($green, 18%); - color: white; - } + select { + margin-left: 7px; + vertical-align: middle; } @media screen and (min-width: 600px) { font-size: 18px; padding-left: 10px; - img { - height: 29px; - width: 29px; - vertical-align: -8px; - margin-right: 8px; - } - button { - width: 120px; - } } } diff --git a/app/assets/stylesheets/settings.scss b/app/assets/stylesheets/settings.scss index d0d4fd757..a0c6932e1 100755 --- a/app/assets/stylesheets/settings.scss +++ b/app/assets/stylesheets/settings.scss @@ -301,6 +301,48 @@ color: $black; background: $yellow; } + .organization-dropdown { + position: relative; + display: inline-block; + color: $black; + background: $yellow; + } + select { + color: inherit; + background: inherit; + vertical-align: baseline; + font-size: inherit; + font-family: inherit; + font-stretch: inherit; + border: 0; + margin: 0; + padding-right: 1.25em; + text-indent: 0.01px; + -moz-appearance: none; + -webkit-appearance: none; + text-overflow: ''; + } + .organization-dropdown::before, + .organization-dropdown::after { + content: ""; + position: absolute; + pointer-events: none; + } + .organization-dropdown::after { /* Custom dropdown arrow */ + content: "\25BC"; + height: 1em; + font-size: 0.625em; + line-height: 1; + right: 0.25em; + top: 50%; + margin-top: -.5em; + } + .organization-dropdown::before { /* Custom dropdown arrow cover */ + width: 1em; + right: 0; + top: 0; + bottom: 0; + } } .secret-field { margin: 30px 0px; diff --git a/app/controllers/api/v0/analytics_controller.rb b/app/controllers/api/v0/analytics_controller.rb index 6a317f53f..d305bd3af 100644 --- a/app/controllers/api/v0/analytics_controller.rb +++ b/app/controllers/api/v0/analytics_controller.rb @@ -9,7 +9,7 @@ module Api data = if params[:organization_id] org = Organization.find_by(id: params[:organization_id]) - raise UnauthorizedError unless org && belongs_to_org?(user, org) + raise UnauthorizedError unless org && user.org_member?(org) AnalyticsService.new(org, article_id: params[:article_id]).totals else @@ -26,7 +26,7 @@ module Api data = if params[:organization_id] org = Organization.find_by(id: params[:organization_id]) - raise UnauthorizedError unless org && belongs_to_org?(user, org) + raise UnauthorizedError unless org && user.org_member?(org) AnalyticsService.new(org, start_date: params[:start], end_date: params[:end], article_id: params[:article_id]).stats_grouped_by_day else @@ -40,7 +40,7 @@ module Api data = if params[:organization_id] org = Organization.find_by(id: params[:organization_id]) - raise UnauthorizedError unless org && belongs_to_org?(user, org) + raise UnauthorizedError unless org && user.org_member?(org) AnalyticsService.new(org, start_date: 1.day.ago, article_id: params[:article_id]).stats_grouped_by_day else @@ -66,10 +66,6 @@ module Api user end - def belongs_to_org?(user, org) - user.organization_id == org.id - end - def valid_date_params? date_regex = /\A\d{4}-\d{1,2}-\d{1,2}\Z/ # for example, 2019-03-22 or 2019-2-1 if params[:end] diff --git a/app/controllers/api/v0/articles_controller.rb b/app/controllers/api/v0/articles_controller.rb index d3025d519..1e3386955 100644 --- a/app/controllers/api/v0/articles_controller.rb +++ b/app/controllers/api/v0/articles_controller.rb @@ -72,11 +72,22 @@ module Api def article_params allowed_params = [ - :title, :body_markdown, :published, :series, :publish_under_org, + :title, :body_markdown, :published, :series, :main_image, :canonical_url, :description, tags: [] ] + allowed_params << :organization_id if params["article"]["organization_id"] && allowed_to_change_org_id? params.require(:article).permit(allowed_params) end + + def allowed_to_change_org_id? + potential_user = @article&.user || @user + if @article.nil? || OrganizationMembership.exists?(user: potential_user, organization_id: params["article"]["organization_id"]) + OrganizationMembership.exists?(user: potential_user, organization_id: params["article"]["organization_id"]) + elsif potential_user == @user + potential_user.org_admin?(params["article"]["organization_id"]) || + @user.any_admin? + end + end end end end diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb index 2d2d47fd8..2f158f2b4 100644 --- a/app/controllers/articles_controller.rb +++ b/app/controllers/articles_controller.rb @@ -73,8 +73,9 @@ class ArticlesController < ApplicationController def edit authorize @article - @organization = @article.user&.organization @version = @article.has_frontmatter? ? "v1" : "v2" + @user = @article.user + @organizations = @user&.organizations end def manage @@ -83,7 +84,8 @@ class ArticlesController < ApplicationController @user = @article.user @rating_vote = RatingVote.where(article_id: @article.id, user_id: @user.id).first @buffer_updates = BufferUpdate.where(composer_user_id: @user.id, article_id: @article.id) - @organization = @user&.organization + @organizations = @user&.organizations + # TODO: fix this for multi orgs @org_members = @organization.users.pluck(:name, :id) if @organization end @@ -180,7 +182,7 @@ class ArticlesController < ApplicationController def base_editor_assigments @user = current_user @version = @user.editor_version if @user - @organization = @user&.organization + @organizations = @user&.organizations @tag = Tag.find_by(name: params[:template]) @prefill = params[:prefill].to_s.gsub("\\n ", "\n").gsub("\\n", "\n") end @@ -235,11 +237,11 @@ class ArticlesController < ApplicationController end allowed_params = if params["article"]["version"] == "v1" - %i[body_markdown organization_id] + %i[body_markdown] else %i[ title body_markdown main_image published description - tag_list organization_id canonical_url series collection_id + tag_list canonical_url series collection_id ] end @@ -247,12 +249,9 @@ class ArticlesController < ApplicationController # fix the bug if params["article"]["user_id"] && org_admin_user_change_privilege allowed_params << :user_id - elsif params["article"]["post_under_org"].to_s == "true" - # add the organization of the article if explicitly asked to do so - params["article"]["organization_id"] = @user.organization_id - elsif params["article"]["post_under_org"].to_s == "false" - # remove the organization of the article if explicitly asked to do so - params["article"]["organization_id"] = nil + elsif params["article"]["organization_id"] && allowed_to_change_org_id? + # change the organization of the article only if explicitly asked to do so + allowed_params << :organization_id end params.require(:article).permit(allowed_params) @@ -272,10 +271,18 @@ class ArticlesController < ApplicationController end end + def allowed_to_change_org_id? + potential_user = @article&.user || current_user + potential_org_id = params["article"]["organization_id"].presence || @article&.organization_id + OrganizationMembership.exists?(user: potential_user, organization_id: potential_org_id) || + current_user.any_admin? + end + def org_admin_user_change_privilege params[:article][:user_id] && - current_user.org_admin && - current_user.organization_id == @article.organization_id && - User.find(params[:article][:user_id])&.organization_id == @article.organization_id + # if current_user is an org admin of the article's org + current_user.org_admin?(@article.organization_id) && + # and if the author being changed to belongs to the article's org + OrganizationMembership.exists?(user_id: params[:article][:user_id], organization_id: @article.organization_id) end end diff --git a/app/controllers/classified_listings_controller.rb b/app/controllers/classified_listings_controller.rb index 8e4d1faec..2bdfbd853 100644 --- a/app/controllers/classified_listings_controller.rb +++ b/app/controllers/classified_listings_controller.rb @@ -17,11 +17,13 @@ class ClassifiedListingsController < ApplicationController def new @classified_listing = ClassifiedListing.new + @organizations = current_user.organizations @credits = current_user.credits.where(spent: false) end def edit authorize @classified_listing + @organizations = current_user.organizations @credits = current_user.credits.where(spent: false) end @@ -29,7 +31,7 @@ class ClassifiedListingsController < ApplicationController @classified_listing = ClassifiedListing.new(classified_listing_params) @classified_listing.user_id = current_user.id @number_of_credits_needed = ClassifiedListing.cost_by_category(@classified_listing.category) - @org = Organization.find(current_user.organization_id) if @classified_listing.post_as_organization.to_i == 1 + @org = Organization.find_by(id: @classified_listing.organization_id) available_org_credits = @org.credits.where(spent: false) if @org available_individual_credits = current_user.credits.where(spent: false) @@ -45,7 +47,8 @@ class ClassifiedListingsController < ApplicationController def create_listing(credits) @classified_listing.bumped_at = Time.current @classified_listing.published = true - @classified_listing.organization_id = current_user.organization_id if @org + # this will 500 for now if they don't belong in the org + authorize @classified_listing, :authorized_organization_poster? if @classified_listing.organization_id.present? if @classified_listing.save clear_listings_cache credits.limit(@number_of_credits_needed).update_all(spent: true) @@ -54,6 +57,7 @@ class ClassifiedListingsController < ApplicationController else @credits = current_user.credits.where(spent: false) @classified_listing.cached_tag_list = classified_listing_params[:tag_list] + @organizations = current_user.organizations render :new end end @@ -94,9 +98,9 @@ class ClassifiedListingsController < ApplicationController @classified_listing = ClassifiedListing.find(params[:id]) end - # Never trust parameters from the scary internet, only allow the white list through. + # Never trust parameters from the scary internet, only allow a specific list through. def classified_listing_params - accessible = %i[title body_markdown category tag_list contact_via_connect post_as_organization action] + accessible = %i[title body_markdown category tag_list contact_via_connect organization_id action] params.require(:classified_listing).permit(accessible) end diff --git a/app/controllers/credits_controller.rb b/app/controllers/credits_controller.rb index 9ba3cad5f..c148e5815 100644 --- a/app/controllers/credits_controller.rb +++ b/app/controllers/credits_controller.rb @@ -3,27 +3,32 @@ class CreditsController < ApplicationController def index @credits = current_user.credits.where(spent: false) - @org_credits = current_user.organization.credits.where(spent: false) if current_user.org_admin + @organizations = current_user.admin_organizations end def new @credit = Credit.new - @purchaser = params[:purchaser] == "organization" ? current_user.organization : current_user + @purchaser = if params[:organization_id].present? && current_user.org_admin?(params[:organization_id]) + Organization.find_by(id: params[:organization_id]) + else + current_user + end + @organizations = current_user.admin_organizations @customer = Stripe::Customer.retrieve(current_user.stripe_id_code) if current_user.stripe_id_code end def create + not_authorized if params[:organization_id].present? && !current_user.org_admin?(params[:organization_id]) + @number_to_purchase = params[:credit][:number_to_purchase].to_i + return unless make_payment - credit_objects = [] - @number_to_purchase.times do - if params[:user_type] == "organization" - raise unless current_user.org_admin - - credit_objects << Credit.new(organization_id: current_user.organization_id, cost: cost_per_credit / 100.0) + credit_objects = Array.new(@number_to_purchase) do + if params[:organization_id].present? + Credit.new(organization_id: params[:organization_id], cost: cost_per_credit / 100.0) else - credit_objects << Credit.new(user_id: current_user.id, cost: cost_per_credit / 100.0) + Credit.new(user_id: current_user.id, cost: cost_per_credit / 100.0) end end Credit.import credit_objects diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index e092efbdb..121bd5612 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -6,17 +6,17 @@ class DashboardsController < ApplicationController def show fetch_and_authorize_user - @org_dashboard = @user&.organization && @user&.org_admin && params[:which] == "organization" + target = @user + not_authorized if params[:org_id] && !@user.org_admin?(params[:org_id] || @user.any_admin?) - if @org_dashboard - articles = @user.organization.articles - @org_members = @user.organization.users.pluck(:name, :id) - else - articles = @user.articles.includes(:organization) + @organizations = @user.admin_organizations.includes(:users) + @member_organizations = @user.member_organizations + + if params[:which] == "organization" && params[:org_id] && (@user.org_admin?(params[:org_id]) || @user.any_admin?) + target = @organizations.find_by(id: params[:org_id]) + @organization = target end - - @articles = articles.sorting(params[:sort]).decorate - + @articles = target.articles.sorting(params[:sort]).decorate # Updates analytics in background if appropriate: ArticleAnalyticsFetcher.new.delay.update_analytics(current_user.id) if @articles && ApplicationConfig["GA_FETCH_RATE"] < 50 # Rate limit concerned, sometimes we throttle down. end @@ -53,6 +53,7 @@ class DashboardsController < ApplicationController authorize current_user, :pro_user? current_user end + @organizations = current_user.member_organizations @dashboard = Dashboard::Pro.new(user_or_org) end diff --git a/app/controllers/notifications/reads_controller.rb b/app/controllers/notifications/reads_controller.rb index 4fa3f15e0..29f95be72 100644 --- a/app/controllers/notifications/reads_controller.rb +++ b/app/controllers/notifications/reads_controller.rb @@ -2,15 +2,11 @@ class Notifications::ReadsController < ApplicationController def create result = "" result = ReadNotificationsService.new(current_user).mark_as_read if current_user - ReadNotificationsService.new(current_user.organization).mark_as_read if params[:org_id]&.to_i && user_belongs_to_org? + if params[:org_id] && current_user.org_member?(params[:org_id]) + org = Organization.find_by(id: params[:org_id]) + ReadNotificationsService.new(org).mark_as_read + end current_user&.touch(:last_notification_activity) render plain: result end - - private - - def user_belongs_to_org? - # this can be changed later with roles - current_user.organization_id == params[:org_id].to_i - end end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 562929473..b11d54cd9 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -22,9 +22,7 @@ class NotificationsController < ApplicationController @last_user_comment = @user.comments.last&.id @notifications = @notifications.where("notified_at < ?", notified_at_offset) if notified_at_offset @notifications = NotificationDecorator.decorate_collection(@notifications.limit(num)) - org_id = params[:org_id] || current_user.organization_id - # in the future this can be an array of numbers, when people can belong to multiple orgs. - @total_org_unread = Notification.where(organization_id: org_id, user_id: nil, read: false).count + @organizations = @user.member_organizations if @user.organizations render partial: "notifications_list" if notified_at_offset end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 00d10d56f..79196aaca 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,14 +8,10 @@ class OrganizationsController < ApplicationController @organization = Organization.new(organization_params) authorize @organization if @organization.save - ActiveRecord::Base.transaction do - current_user.update(organization_id: @organization.id, org_admin: true) - OrganizationMembership.create(organization_id: @organization.id, user_id: current_user.id, type_of_user: "admin") - end - redirect_to "/settings/organization", notice: - "Your organization was successfully created and you are an admin." + @organization_membership = OrganizationMembership.create!(organization_id: @organization.id, user_id: current_user.id, type_of_user: "admin") + flash[:settings_notice] = "Your organization was successfully created and you are an admin." + redirect_to "/settings/organization/#{@organization.id}" else - @tab = "switch-organizations" if @user.has_role?(:switch_between_orgs) render template: "users/edit" end end @@ -24,30 +20,31 @@ class OrganizationsController < ApplicationController @user = current_user @tab = "organization" @tab_list = @user.settings_tab_list - @organization = @user.organization + @organization = Organization.find_by(id: organization_params[:id]) authorize @organization if @organization.update(organization_params.merge(profile_updated_at: Time.current)) - redirect_to "/settings/organization", notice: "Your organization was successfully updated." + flash[:settings_notice] = "Your organization was successfully updated." + redirect_to "/settings/organization" else render template: "users/edit" end end def generate_new_secret - raise unless current_user.org_admin - - @organization = current_user.organization + @organization = Organization.find_by(id: organization_params[:id]) authorize @organization @organization.secret = @organization.generated_random_secret @organization.save - redirect_to "/settings/organization", notice: "Your org secret was updated" + flash[:settings_notice] = "Your org secret was updated" + redirect_to "/settings/organization" end private def permitted_params accessible = %i[ + id name summary tag_line diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index eb5f2407b..70104ec27 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,6 +1,6 @@ class UsersController < ApplicationController before_action :set_no_cache_header - after_action :verify_authorized, except: [:signout_confirm] + after_action :verify_authorized, except: %i[signout_confirm add_org_admin remove_org_admin remove_from_org] # GET /settings/@tab def edit @@ -26,8 +26,9 @@ class UsersController < ApplicationController end cookies.permanent[:user_experience_level] = @user.experience_level.to_s if @user.experience_level.present? follow_hiring_tag(@user) + flash[:settings_notice] = notice @user.touch(:profile_updated_at) - redirect_to "/settings/#{@tab}", notice: notice + redirect_to "/settings/#{@tab}" else render :edit end @@ -41,10 +42,10 @@ class UsersController < ApplicationController if @user.update(twitch_username: new_twitch_username) @user.touch(:profile_updated_at) Streams::TwitchWebhookRegistrationJob.perform_later(@user.id) if @user.twitch_username? - notice = "Your profile was successfully updated." end + flash[:settings_notice] = "Your Twitch username was successfully updated." end - redirect_to "/settings/#{@tab}", notice: notice + redirect_to "/settings/#{@tab}" end def update_language_settings @@ -52,9 +53,9 @@ class UsersController < ApplicationController set_tabs("misc") @user.language_settings["preferred_languages"] = Languages::LIST.keys & params[:user][:preferred_languages].to_a if @user.save - notice = "Your profile was successfully updated." + flash[:settings_notice] = "Your language settings were successfully updated." @user.touch(:profile_updated_at) - redirect_to "/settings/#{@tab}", notice: notice + redirect_to "/settings/#{@tab}" else render :edit end @@ -66,8 +67,9 @@ class UsersController < ApplicationController if @user.articles_count.zero? && @user.comments_count.zero? @user.destroy! NotifyMailer.account_deleted_email(@user).deliver + flash[:settings_notice] = "Your account has been deleted." sign_out @user - redirect_to root_path, notice: "Your account has been deleted." + redirect_to root_path else flash[:error] = "An error occurred. Try requesting an account deletion below." redirect_to "/settings/#{@tab}" @@ -86,12 +88,11 @@ class UsersController < ApplicationController identity_username = "#{provider}_username".to_sym @user.update(identity_username => nil, profile_updated_at: Time.current) - redirect_to "/settings/#{@tab}", - notice: "Your #{provider.capitalize} account was successfully removed." + flash[:settings_notice] = "Your #{provider.capitalize} account was successfully removed." else flash[:error] = "An error occurred. Please try again or send an email to: yo@dev.to" - redirect_to "/settings/#{@tab}" end + redirect_to "/settings/#{@tab}" end def onboarding_update @@ -112,57 +113,55 @@ class UsersController < ApplicationController def join_org authorize User if (@organization = Organization.find_by(secret: params[:org_secret].strip)) - ActiveRecord::Base.transaction do - current_user.update(organization_id: @organization.id) - OrganizationMembership.create(user_id: current_user.id, organization_id: current_user.organization_id, type_of_user: "member") - end - redirect_to "/settings/organization", - notice: "You have joined the #{@organization.name} organization." + OrganizationMembership.create(user_id: current_user.id, organization_id: @organization.id, type_of_user: "member") + flash[:settings_notice] = "You have joined the #{@organization.name} organization." + redirect_to "/settings/organization/#{@organization.id}" else flash[:error] = "The given organization secret was invalid." - redirect_to "/settings/organization" + redirect_to "/settings/organization/new" end end def leave_org - authorize User - type_of_user = current_user.org_admin ? "admin" : "member" - OrganizationMembership.find_by(organization_id: current_user.organization_id, user_id: current_user.id, type_of_user: type_of_user)&.delete - current_user.update(organization_id: nil, org_admin: nil) - redirect_to "/settings/organization", - notice: "You have left your organization." + org = Organization.find_by(id: params[:organization_id]) + authorize org + OrganizationMembership.find_by(organization_id: org.id, user_id: current_user.id)&.delete + flash[:settings_notice] = "You have left your organization." + redirect_to "/settings/organization/new" end def add_org_admin - user = User.find(params[:user_id]) - authorize user - user.update(org_admin: true) - org_membership = OrganizationMembership.find_or_initialize_by(user_id: user.id, organization_id: user.organization_id) - org_membership.type_of_user = "admin" - org_membership.save - redirect_to "/settings/organization", - notice: "#{user.name} is now an admin." + adminable = User.find(params[:user_id]) + org = Organization.find_by(id: params[:organization_id]) + + not_authorized unless current_user.org_admin?(org) && OrganizationMembership.exists?(user: adminable, organization: org) + + OrganizationMembership.find_by(user_id: adminable.id, organization_id: org.id).update(type_of_user: "admin") + flash[:settings_notice] = "#{adminable.name} is now an admin." + redirect_to "/settings/organization/#{org.id}" end def remove_org_admin - user = User.find(params[:user_id]) - authorize user - user.update(org_admin: false) - org_membership = OrganizationMembership.find_or_initialize_by(user_id: user.id, organization_id: user.organization_id) - org_membership.type_of_user = "member" - org_membership.save - redirect_to "/settings/organization", - notice: "#{user.name} is no longer an admin." + unadminable = User.find(params[:user_id]) + org = Organization.find_by(id: params[:organization_id]) + + not_authorized unless current_user.org_admin?(org) && unadminable.org_admin?(org) + + OrganizationMembership.find_by(user_id: unadminable.id, organization_id: org.id).update(type_of_user: "member") + flash[:settings_notice] = "#{unadminable.name} is no longer an admin." + redirect_to "/settings/organization/#{org.id}" end def remove_from_org - user = User.find(params[:user_id]) - authorize user - type_of_user = user.org_admin ? "admin" : "member" - OrganizationMembership.find_by(organization_id: current_user.organization_id, user_id: current_user.id, type_of_user: type_of_user)&.delete - user.update(organization_id: nil) - redirect_to "/settings/organization", - notice: "#{user.name} is no longer part of your organization." + removable = User.find(params[:user_id]) + org = Organization.find_by(id: params[:organization_id]) + removable_org_membership = OrganizationMembership.find_by(user_id: removable.id, organization_id: org.id) + + not_authorized unless current_user.org_admin?(org) && removable_org_membership + + removable_org_membership.delete + flash[:settings_notice] = "#{removable.name} is no longer part of your organization." + redirect_to "/settings/organization/#{org.id}" end def signout_confirm; end @@ -178,9 +177,7 @@ class UsersController < ApplicationController case @tab when "organization" - @organization = @user.organization || Organization.new - when "switch-organizations" - @organization = Organization.new + handle_organization_tab when "integrations" if current_user.identities.where(provider: "github").any? @client = Octokit::Client. @@ -221,6 +218,19 @@ class UsersController < ApplicationController private + def handle_organization_tab + @organizations = @current_user.organizations.order("name ASC") + if params[:org_id] == "new" || params[:org_id].blank? && @organizations.size.zero? + @organization = Organization.new + elsif params[:org_id].blank? || params[:org_id].match?(/\d/) + @organization = Organization.find_by(id: params[:org_id]) || @organizations.first + authorize @organization, :part_of_org? + + @org_organization_memberships = @organization.organization_memberships.includes(:user) + @organization_membership = OrganizationMembership.find_by(user_id: current_user.id, organization_id: @organization.id) + end + end + def set_user @user = current_user authorize @user diff --git a/app/javascript/article-form/__tests__/__snapshots__/articleForm.test.jsx.snap b/app/javascript/article-form/__tests__/__snapshots__/articleForm.test.jsx.snap index 63b2df629..3141c0eb6 100644 --- a/app/javascript/article-form/__tests__/__snapshots__/articleForm.test.jsx.snap +++ b/app/javascript/article-form/__tests__/__snapshots__/articleForm.test.jsx.snap @@ -46,16 +46,20 @@ exports[` renders properly 1`] = ` @@ -79,6 +83,7 @@ exports[` renders properly 1`] = ` @@ -381,6 +383,7 @@ export default class ArticleForm extends Component { +const orgOptions = (organizations, organizationId) => { + const orgs = organizations.map((organization) => { + if(organizationId === organization.id) { + return( + + ) + } + return ( + + ) + }) + const nullOrgOption = organizationId === null ? : + orgs.unshift(nullOrgOption) // make first option as "None" + return orgs +} + +const OrgSettings = ({ organizations, organizationId, onToggle }) => ( +
+ Publish under an organization: +
); OrgSettings.propTypes = { - onChange: PropTypes.func.isRequired, - defaultValue: PropTypes.string.isRequired, + onToggle: PropTypes.func.isRequired, }; export default OrgSettings; diff --git a/app/javascript/listings/elements/orgSettings.jsx b/app/javascript/listings/elements/orgSettings.jsx new file mode 100644 index 000000000..1e9f16a4a --- /dev/null +++ b/app/javascript/listings/elements/orgSettings.jsx @@ -0,0 +1,35 @@ +// listings +import { h } from 'preact'; +import PropTypes from 'prop-types'; + +const orgOptions = (organizations, organizationId) => { + const orgs = organizations.map((organization) => { + if (organizationId === organization.id) { + return ( + + ) + } + return ( + + ) + }) + const nullOrgOption = organizationId === null ? : + orgs.unshift(nullOrgOption) // make first option as "None" + return orgs +} + +const OrgSettings = ({ organizations, organizationId, onToggle }) => ( +
+ + +

Posting on behalf of org spends org credits.

+
+); + +OrgSettings.propTypes = { + onToggle: PropTypes.func.isRequired, +}; + +export default OrgSettings; diff --git a/app/javascript/listings/elements/organizationField.jsx b/app/javascript/listings/elements/organizationField.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/javascript/listings/listingForm.jsx b/app/javascript/listings/listingForm.jsx index b096fcfd6..70f6d84cb 100644 --- a/app/javascript/listings/listingForm.jsx +++ b/app/javascript/listings/listingForm.jsx @@ -4,7 +4,7 @@ import Title from './elements/title'; import BodyMarkdown from './elements/bodyMarkdown'; import Categories from './elements/categories'; import Tags from './elements/tags'; - +import OrgSettings from './elements/orgSettings'; export default class ListingForm extends Component { constructor(props) { @@ -14,8 +14,7 @@ export default class ListingForm extends Component { this.categoriesForDetails = JSON.parse(this.props.categoriesForDetails); this.categoriesForSelect = JSON.parse(this.props.categoriesForSelect); - const organizations = this.props.organizations - ? JSON.parse(this.props.organizations) : null; + const organizations = JSON.parse(this.props.organizations); this.url = window.location.href; @@ -25,12 +24,18 @@ export default class ListingForm extends Component { category: this.listing.category || '', tagList: this.listing.cached_tag_list || '', bodyMarkdown: this.listing.body_markdown || '', - organizations: organizations || null, categoriesForSelect: this.categoriesForSelect, categoriesForDetails: this.categoriesForDetails, + organizations, + organizationId: null, // change this for /edit later } } + handleOrgIdChange = e => { + const organizationId = e.target.selectedOptions[0].value; + this.setState({ organizationId }) + } + render() { const { id, @@ -38,10 +43,20 @@ export default class ListingForm extends Component { bodyMarkdown, tagList, category, - organizations, categoriesForDetails, categoriesForSelect, + organizations, + organizationId, } = this.state; + const orgArea = organizations ? ( + + ) : ( + '' + ); if (id === null) { return(
@@ -49,15 +64,18 @@ export default class ListingForm extends Component { + {orgArea} {/* add contact via connect checkbox later */}
) } + // WIP code for edit return(
<BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} /> <Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} /> + {orgArea} {/* add contact via connect checkbox later */} </div> ) diff --git a/app/javascript/packs/articleForm.jsx b/app/javascript/packs/articleForm.jsx index 71ba231dd..e8e3bbcff 100644 --- a/app/javascript/packs/articleForm.jsx +++ b/app/javascript/packs/articleForm.jsx @@ -25,10 +25,10 @@ function loadForm() { window.csrfToken = csrfToken; const root = document.getElementById('article-form'); - const { article, organization, version } = root.dataset; + const { article, organizations, version } = root.dataset; render( - <ArticleForm article={article} organization={organization} version={version} />, + <ArticleForm article={article} organizations={organizations} version={version} />, root, root.firstElementChild, ); diff --git a/app/javascript/packs/orgCreditsSelector.js b/app/javascript/packs/orgCreditsSelector.js new file mode 100644 index 000000000..1ec606c7a --- /dev/null +++ b/app/javascript/packs/orgCreditsSelector.js @@ -0,0 +1,15 @@ +const orgCreditsSelect = document.getElementById('org-credits-select') +const orgCreditsNumber = document.getElementById('org-credits-number') +const orgCreditsLink = document.getElementById('org-credits-purchase-link') + +orgCreditsNumber.innerText = orgCreditsSelect.selectedOptions[0].dataset.credits + +const changeOrgCredits = (event) => { + const selectedOrgCreditsCount = event.target.selectedOptions[0].dataset.credits + const selectedOrgId = event.target.selectedOptions[0].value + + orgCreditsNumber.innerText = selectedOrgCreditsCount + orgCreditsLink.href = `/credits/purchase?organization_id=${selectedOrgId}` +} + +orgCreditsSelect.addEventListener('change', changeOrgCredits) diff --git a/app/models/article.rb b/app/models/article.rb index cbe4f9597..373fabef7 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -15,9 +15,12 @@ class Article < ApplicationRecord belongs_to :user belongs_to :job_opportunity, optional: true - counter_culture :user belongs_to :organization, optional: true belongs_to :collection, optional: true + + counter_culture :user + counter_culture :organization + has_many :comments, as: :commentable, inverse_of: :commentable has_many :buffer_updates has_many :notifications, as: :notifiable, inverse_of: :notifiable diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb index b2bdbdb8b..045bfb4dd 100644 --- a/app/models/classified_listing.rb +++ b/app/models/classified_listing.rb @@ -1,7 +1,7 @@ class ClassifiedListing < ApplicationRecord include AlgoliaSearch - attr_accessor :post_as_organization, :action + attr_accessor :action belongs_to :user, optional: true belongs_to :organization, optional: true diff --git a/app/models/credit.rb b/app/models/credit.rb index d8de5c1d3..7205cd474 100644 --- a/app/models/credit.rb +++ b/app/models/credit.rb @@ -4,8 +4,20 @@ class Credit < ApplicationRecord belongs_to :user, optional: true belongs_to :organization, optional: true - counter_culture :user # Only counting credits_count right now. TODO: spent_credits_count and unspent_credits_count (counter culture gem) - counter_culture :organization # Only counting credits_count right now. TODO: spent_credits_count and unspent_credits_count (counter culture gem) + counter_culture :user, + column_name: proc { |model| "#{model.spent ? 'spent' : 'unspent'}_credits_count" }, + column_names: { + ["credits.spent = ?", true] => "spent_credits_count", + ["credits.spent = ?", false] => "unspent_credits_count", + ["credits.id > ?", 0] => "credits_count" + } + counter_culture :organization, + column_name: proc { |model| "#{model.spent ? 'spent' : 'unspent'}_credits_count" }, + column_names: { + ["credits.spent = ?", true] => "spent_credits_count", + ["credits.spent = ?", false] => "unspent_credits_count", + ["credits.id > ?", 0] => "credits_count" + } def self.add_to(user, amount) credit_objects = [] diff --git a/app/models/organization.rb b/app/models/organization.rb index de4f70be6..d3281193d 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -4,13 +4,15 @@ class Organization < ApplicationRecord acts_as_followable has_many :job_listings - has_many :users + has_many :organization_memberships + has_many :users, through: :organization_memberships has_many :api_secrets, through: :users has_many :articles has_many :collections has_many :display_ads has_many :notifications has_many :credits + has_many :unspent_credits, -> { where spent: false }, class_name: "Credit", inverse_of: :organization has_many :classified_listings validates :name, :summary, :url, :profile_image, presence: true diff --git a/app/models/organization_membership.rb b/app/models/organization_membership.rb index 972a48f14..83913cc65 100644 --- a/app/models/organization_membership.rb +++ b/app/models/organization_membership.rb @@ -1,4 +1,7 @@ class OrganizationMembership < ApplicationRecord + belongs_to :user + belongs_to :organization + validates :user_id, :organization_id, :type_of_user, presence: true validates :user_id, uniqueness: { scope: :organization_id } validates :type_of_user, inclusion: { in: %w[admin member guest] } diff --git a/app/models/role.rb b/app/models/role.rb index 5e64be924..32aba97ce 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -18,7 +18,6 @@ class Role < ApplicationRecord trusted banned warned - switch_between_orgs triple_unicorn_member level_4_member level_3_member diff --git a/app/models/user.rb b/app/models/user.rb index 5cd285f6d..6a51ffce5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,7 +10,8 @@ class User < ApplicationRecord acts_as_followable acts_as_follower - belongs_to :organization, optional: true + has_many :organization_memberships + has_many :organizations, through: :organization_memberships has_many :api_secrets, dependent: :destroy has_many :articles, dependent: :destroy has_many :badge_achievements, dependent: :destroy @@ -351,8 +352,22 @@ class User < ApplicationRecord has_any_role?(:workshop_pass, :level_3_member, :level_4_member, :triple_unicorn_member) end + def admin_organizations + org_ids = organization_memberships.where(type_of_user: "admin").pluck(:organization_id) + organizations.where(id: org_ids) + end + + def member_organizations + org_ids = organization_memberships.where(type_of_user: %w[admin member]).pluck(:organization_id) + organizations.where(id: org_ids) + end + + def org_member?(organization) + OrganizationMembership.exists?(user: user, organization: organization, type_of_user: %w[admin member]) + end + def org_admin?(organization) - user.org_admin && user.organization_id == organization.id + OrganizationMembership.exists?(user: user, organization: organization, type_of_user: "admin") end def unique_including_orgs diff --git a/app/policies/article_policy.rb b/app/policies/article_policy.rb index daffaf1a7..3e18b08f0 100644 --- a/app/policies/article_policy.rb +++ b/app/policies/article_policy.rb @@ -41,6 +41,6 @@ class ArticlePolicy < ApplicationPolicy end def user_org_admin? - user.org_admin && user.organization_id == record.organization_id + user.org_admin?(record.organization_id) end end diff --git a/app/policies/classified_listing_policy.rb b/app/policies/classified_listing_policy.rb index defdb7247..22ee9380b 100644 --- a/app/policies/classified_listing_policy.rb +++ b/app/policies/classified_listing_policy.rb @@ -7,6 +7,10 @@ class ClassifiedListingPolicy < ApplicationPolicy user_is_author? end + def authorized_organization_poster? + user.org_member?(record.organization_id) + end + private def user_is_author? diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb index de8a25dd7..3034230b4 100644 --- a/app/policies/organization_policy.rb +++ b/app/policies/organization_policy.rb @@ -7,11 +7,21 @@ class OrganizationPolicy < ApplicationPolicy user.org_admin?(record) end + def leave_org? + part_of_org? + end + + def part_of_org? + return false if record.blank? + + OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) + end + def generate_new_secret? update? end def pro_org_user? - user.has_role?(:pro) && user.organization_id == record.id + user.has_role?(:pro) && OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) end end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 03ad920ad..5bb644c02 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -28,19 +28,7 @@ class UserPolicy < ApplicationPolicy end def leave_org? - true - end - - def add_org_admin? - user.org_admin && within_the_same_org? - end - - def remove_org_admin? - user.org_admin && not_self? && within_the_same_org? - end - - def remove_from_org? - user.org_admin && not_self? && within_the_same_org? + OrganizationMembership.exists?(user_id: user.id, organization_id: record.id) end def remove_association? @@ -127,10 +115,6 @@ class UserPolicy < ApplicationPolicy private - def within_the_same_org? - user.organization == record.organization - end - def not_self? user != record end diff --git a/app/services/article_creation_service.rb b/app/services/article_creation_service.rb index 5090b69bb..d7791e7d4 100644 --- a/app/services/article_creation_service.rb +++ b/app/services/article_creation_service.rb @@ -9,10 +9,6 @@ class ArticleCreationService tags = article_params[:tags] series = article_params[:series] - publish_under_org = ( - article_params[:publish_under_org] == true || - article_params[:publish_under_org].to_i == 1 - ) # convert tags from array to a string if tags.present? @@ -23,7 +19,6 @@ class ArticleCreationService article = Article.new(article_params) article.user_id = user.id article.show_comments = true - article.organization = user.organization if publish_under_org article.collection = Collection.find_series(series, user) if series.present? if article.save diff --git a/app/services/articles/updater.rb b/app/services/articles/updater.rb index 0800f59e8..671b52381 100644 --- a/app/services/articles/updater.rb +++ b/app/services/articles/updater.rb @@ -27,9 +27,6 @@ module Articles article_params.delete(:tags) end - # add organization if requested - article_params[:organization_id] = user.organization_id if article_params[:publish_under_org] == true - # updated edited time only if already published and not edited by an admin update_edited_at = article.user == user && article.published article_params[:edited_at] = Time.current if update_edited_at diff --git a/app/services/rss_reader.rb b/app/services/rss_reader.rb index 4ac3ba995..b51aa4d47 100644 --- a/app/services/rss_reader.rb +++ b/app/services/rss_reader.rb @@ -77,7 +77,7 @@ class RssReader published_from_feed: true, show_comments: true, body_markdown: RssReader::Assembler.call(item, user, feed, feed_source_url), - organization_id: user.organization_id.presence, + organization_id: nil, ) send_slack_notification(article) diff --git a/app/views/articles/_v2_form.html.erb b/app/views/articles/_v2_form.html.erb index bb09b2c4c..4bfe52d8b 100644 --- a/app/views/articles/_v2_form.html.erb +++ b/app/views/articles/_v2_form.html.erb @@ -2,16 +2,15 @@ <div class="articleform" id="article-form" data-article="<%= article.to_json(only: %i[id title cached_tag_list published body_markdown main_image organization_id canonical_url], methods: %i[series all_series]) %>" - data-organization="<%= organization&.to_json(only: %i[name bg_color_hex text_color_hex], methods: [:profile_image_90]) %>" + data-organizations="<%= organizations&.to_json(only: %i[id name bg_color_hex text_color_hex], methods: [:profile_image_90]) %>" data-version="<%= version %>"> <form class="articleform__form articleform__form--<%= version %>"> - <% if organization %> + <% if organizations %> <div class="articleform__orgsettings"> - <img src="<%= organization.profile_image_90 %>" style="opacity: <%= article.organization ? "1.0" : "0.7" %>" alt="<%= organization.name %> profile image"> - <%= organization.name %> - <button class="<%= article.organization ? "yes" : "no" %>"> - <%= article.organization ? "✅ YES" : "◻️ NO" %> - </button> + Publish under an organization: + <select name="article[organization_id]" id="article_publish_under_org"> + <%= options_for_select({ "None" => "" }.merge(organizations.pluck(:name, :id).to_h), article.organization_id || "") %> + </select> </div> <% end %> <% if version == "v2" %> diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb index 491c7779c..bbef9eec5 100644 --- a/app/views/articles/edit.html.erb +++ b/app/views/articles/edit.html.erb @@ -28,4 +28,4 @@ <% end %> <%= javascript_pack_tag "articleForm", defer: true %> -<%= render "articles/v2_form", article: @article, organization: @organization, version: @version %> +<%= render "articles/v2_form", article: @article, organizations: @organizations, version: @version %> diff --git a/app/views/articles/manage.html.erb b/app/views/articles/manage.html.erb index 07883ae86..cf0ca75b8 100644 --- a/app/views/articles/manage.html.erb +++ b/app/views/articles/manage.html.erb @@ -23,7 +23,7 @@ </div> <div class="dashboard-container"> - <%= render "dashboards/dashboard_article", article: @article, org_admin: @user.org_admin, manage_view: true %> + <%= render "dashboards/dashboard_article", article: @article, organization: @article.organization, org_admin: @user.org_admin?(@article.organization), manage_view: true %> <% if @buffer_updates.any? %> <div class="single-article thanks-for-suggesting-a-tweet"> Tweet Suggestion Sent 🙏 diff --git a/app/views/articles/new.html.erb b/app/views/articles/new.html.erb index 52486c036..07abb0d64 100644 --- a/app/views/articles/new.html.erb +++ b/app/views/articles/new.html.erb @@ -2,7 +2,7 @@ <% if user_signed_in? %> <%= javascript_pack_tag "articleForm", defer: true %> - <%= render "articles/v2_form", article: @article, organization: @organization, version: @version %> + <%= render "articles/v2_form", article: @article, organizations: @organizations, version: @version %> <% else %> <% @new_article_not_logged_in = true %> <%= render "devise/registrations/registration_form" %> diff --git a/app/views/classified_listings/_form.html.erb b/app/views/classified_listings/_form.html.erb index d07a6696c..708d8d9a6 100644 --- a/app/views/classified_listings/_form.html.erb +++ b/app/views/classified_listings/_form.html.erb @@ -1,13 +1,12 @@ <%= form_with(model: classified_listing, local: true) do |form| %> <header> - <a href="/listings" class="listings-back-button">< go back to listings</a> + <a href="/listings" class="listings-back-button">< go back to listings</a> <h2>Create a Community Listing</h2> </header> <div class="classified-form-inner"> <% if classified_listing.errors.any? %> <div class="classified-errors"> <h2><%= pluralize(classified_listing.errors.count, "error") %> prohibited this listing from being saved:</h2> - <ul> <% classified_listing.errors.full_messages.each do |message| %> <li><%= message %></li> @@ -17,11 +16,11 @@ <% end %> <div id="listingform-data" data-listing="<%= classified_listing.to_json(only: %i[id title body_markdown category cached_tag_list]) %>" - data-organizations="<%= @organizations.to_json %>" + data-organizations="<%= @organizations.to_json(only: %i[id name]) %>" data-categories-for-select="<%= ClassifiedListing.select_options_for_categories.to_json %>" - data-categories-for-details="<%= ClassifiedListing.categories_available.transform_values{ |value_hash| value_hash.except(:cost) }.values.to_json %>" + data-categories-for-details="<%= ClassifiedListing.categories_available.transform_values { |value_hash| value_hash.except(:cost) }.values.to_json %>" > - <div style="height: 650px;"> + <div style="height: 745px;"> <div class="field"> <%= form.label "title" %> <%= form.text_field "title", placeholder: "128 characters max, plain text" %> @@ -46,41 +45,48 @@ <%= form.label "tags_list", "Tags" %> <%= form.text_field "tags_list", placeholder: "8 tags max, comma separated, no spaces or special characters" %> </div> + <% if @organizations.present? %> + <div class="field"> + <%= form.label "organization_id", "Post under an organization:" %> + <%= form.select :organization_id, "None" => "None" %> + <p><em>Posting on behalf of an organization spends the organization's credits.</em></p> + </div> + <% end %> </div> </div> - <%= javascript_pack_tag "listingForm", defer: true %> + <%= javascript_pack_tag "listingForm", defer: true %> <div class="field"> <%= form.label "contact_via_connect", "Allow Users to Message Me Via In-App Chat (DEV Connect)" %> <%= form.check_box "contact_via_connect" %> </div> - <% if current_user.organization && current_user.org_admin %> - <div class="field"> - <%= form.label "post_as_organization", "Post as #{current_user.organization.name} organization" %> - <%= form.check_box "post_as_organization" %> - </div> - <p><em>Posting on behalf of org spends org credits.</em></p> - <% end %> <div class="actions"> - <% if @credits.size > 0 || (current_user.organization && current_user.organization.credits.where(spent: false).size > 0)%> + <% if @credits.size > 0 || (@organizations.present? && @organizations.sum(:unspent_credits_count) > 0) %> <%= form.submit "CREATE LISTING", class: "cta cta-main-listing-form" %> <% else %> <p>You need at least one credit to create a listing.</p> - <a href="/credits/new" class="cta cta-main-listing-form">Purchase Credits Now</a> + <a href="/credits/new" class="cta cta-main-listing-form" data-no-instant>Purchase Credits Now</a> <% end %> </div> <p><em><%= ApplicationConfig["COMMUNITY_NAME"] %> admins may modify tags and categories to ensure accuracy. We may also take down and refund inappropriate listings.</em></p> <p><em>Listings expire 30 days after they are published or "bumped".</em></p> <div class="listings-current-credits"> - <% if @credits.size == 0 && (!current_user.organization || current_user.organization.credits.where(spent: false).size == 0) %> + <% if @credits.size == 0 && (@organizations.blank? || @organizations.sum(:unspent_credits_count).zero?) %> <h3>Listings Require Credits</h3> <% end %> <div class="listings-current-credits-inner"> You have <%= pluralize @credits.size, "credit" %> available — <a href="/credits/purchase" data-no-instant>Buy More</a> </div> - <% if current_user.organization && current_user.org_admin %> + <% if @organizations.present? %> <div class="listings-current-credits-inner"> - <%= current_user.organization.name %> has <%= current_user.organization.credits.where(spent: false).size %> — <a href="/credits/purchase?purchaser=organization" data-no-instant>Buy More</a> + <select id="org-credits-select" class="org-credits-select"> + <% @organizations.each do |org| %> + <option value="<%= org.id %>" data-credits="<%= org.unspent_credits_count %>"><%= org.name %></option> + <% end %> + </select> + has <span id="org-credits-number"><%= @organizations.first.unspent_credits_count %></span> credits - + <a id="org-credits-purchase-link" href="/credits/purchase?organization_id=<%= @organizations.first.id %>" data-no-instant>Buy More</a> </div> + <%= javascript_pack_tag "orgCreditsSelector", defer: true %> <% end %> </div> </div> diff --git a/app/views/credits/index.html.erb b/app/views/credits/index.html.erb index b285897cf..5b3a2185b 100644 --- a/app/views/credits/index.html.erb +++ b/app/views/credits/index.html.erb @@ -8,16 +8,23 @@ <div class="existing-credits-status">You have <%= @credits.size %> credits to spend</div> <a href="/credits/purchase" data-no-instant>Purchase additional credits</a> </div> - <% if current_user.organization && current_user.org_admin %> - <div class="existing-credits"> - <div class="existing-credits-status"><%= current_user.organization.name %> has <%= @org_credits.size %> credits to spend</div> - <a href="/credits/purchase?purchaser=organization" data-no-instant>Purchase additional credits</a> + <% if @organizations.present? %> + <div class="existing-credits"> + <div class="existing-credits-status"> + <select id="org-credits-select" class="org-credits-select"> + <% @organizations.each do |org| %> + <option value="<%= org.id %>" data-credits="<%= org.unspent_credits_count %>"><%= org.name %></option> + <% end %> + </select> + has <span id="org-credits-number"><%= @organizations.first.unspent_credits_count %></span> credits to spend </div> + <a id="org-credits-purchase-link" href="/credits/purchase?organization_id=<%= @organizations.first.id %>" data-no-instant>Purchase additional credits</a> + <%= javascript_pack_tag "orgCreditsSelector", defer: true %> + </div> <% end %> <% if @credits.size > 0 %> <center> <a href="/listings/new">Create a Listing</a> </center> <% end %> - -</div> \ No newline at end of file +</div> diff --git a/app/views/credits/new.html.erb b/app/views/credits/new.html.erb index 91169fe4f..3c2ba2a87 100644 --- a/app/views/credits/new.html.erb +++ b/app/views/credits/new.html.erb @@ -3,13 +3,16 @@ <div class="credits-container"> <div class="credits-banner"> <h1>Purchase credits for @<%= @purchaser.username %></h1> - <% if current_user.org_admin %> - <% if @purchaser.username == current_user.username %> - <a href="/credits/purchase?purchaser=organization" data-no-instant>Switch to <%= current_user.organization.name %></a> - <% else %> + <% if @organizations %> + <% @organizations.each do |org| %> + <% unless @purchaser.id == org.id %> + <a href="/credits/purchase?organization_id=<%= org.id %>" data-no-instant>Switch to <%= org.name %></a> + <% end %> + <% end %> + <% end %> + <% unless @purchaser.is_a? User %> <a href="/credits/purchase" data-no-instant>Switch to personal purchase</a> <% end %> - <% end %> </div> <script src="https://js.stripe.com/v3/"></script> <p class="hook-description">Credits are used for <a href="/listings">DEV listings</a> as well as upcoming products and services.</p> @@ -61,11 +64,11 @@ </details> <details> <summary> - How many credits <%= params[:purchaser] == "organization" ? "does #{@purchaser.name}" : "do I currently" %> have? + How many credits <%= params[:organization_id].present? ? "does #{@purchaser.name}" : "do I currently" %> have? </summary> <div> <p> - <%= params[:purchaser] == "organization" ? "#{@purchaser.name} has" : "You have" %> <%= @purchaser.credits.where(spent: false).size %> credits. + <%= params[:organization_id].present? ? "#{@purchaser.name} has" : "You have" %> <%= @purchaser.credits.where(spent: false).size %> credits. </p> </div> </details> @@ -85,17 +88,15 @@ </label> <div class="amount-wrapper"> <%= f.number_field :number_to_purchase, - id: "amount-input", - placeholder: "Enter any amount", autocomplete: "off", - min: "0", - max: "10000000", - required: true %> + id: "amount-input", + placeholder: "Enter any amount", autocomplete: "off", + min: "0", + max: "10000000", + required: true %> <div id="calculated-price"></div> </div> - <% if params[:purchaser] == "organization" %> - <div> - <input type="hidden" name="user_type" value="organization"> - </div> + <% if @purchaser.is_a? Organization %> + <input type="hidden" name="organization_id" value="<%= @purchaser.id %>"> <% end %> <div class="form-row"> <label for="card-element" class="main-form-label"> @@ -125,7 +126,7 @@ <div id="card-errors" role="alert"></div> </div> <button id="add-credit-card-button" class="credit-card-button">Complete Purchase</button> - <% if params[:purchaser] == "organization" %> + <% if @purchaser.is_a? Organization %> <p><em>*All org admins have access to credits owned by organization</em></p> <% end %> <% end %> diff --git a/app/views/dashboards/_dashboard_article.html.erb b/app/views/dashboards/_dashboard_article.html.erb index f8a0b1216..a5b59e7b8 100644 --- a/app/views/dashboards/_dashboard_article.html.erb +++ b/app/views/dashboards/_dashboard_article.html.erb @@ -54,7 +54,7 @@ <% end %> <% if article.published %> <span id="pageviews-<%= article.id %>" class="cta pill dashboard-pageviews-indicator" data-analytics-pageviews data-article-id="<%= article.id %>"> - <span class='page-views-count'> + <span class="page-views-count"> <span class="value"> <% if article.page_views_count > 100 %> <span class="value"><%= article.page_views_count %></span> @@ -65,14 +65,14 @@ views </span> // - <span class='reactions-count'> + <span class="reactions-count"> <span class="value"> <%= article.positive_reactions_count %> </span> <%= "reaction".pluralize(count: article.positive_reactions_count) %> </span> // - <span class='comments-count'> + <span class="comments-count"> <span class="value"> <%= article.comments_count %> </span> @@ -81,10 +81,10 @@ </span> <% end %> </div> - <% if org_admin %> + <% if organization && org_admin %> <%= form_for(article) do |f| %> - <input type="hidden" name="destination" value="/dashboard" /> - AUTHOR: <%= f.select(:user_id, options_for_select(@org_members, article.user_id)) %> + <input type="hidden" name="destination" value="/dashboard/organization/<%= organization.id %>" /> + AUTHOR: <%= f.select(:user_id, options_for_select(organization.users.pluck(:name, :id), article.user_id)) %> <%= f.submit "SUBMIT AUTHOR CHANGE", class: "cta pill black" %> <% end %> <% end %> diff --git a/app/views/dashboards/followers.html.erb b/app/views/dashboards/followers.html.erb index c85e20d69..518492c3c 100644 --- a/app/views/dashboards/followers.html.erb +++ b/app/views/dashboards/followers.html.erb @@ -2,15 +2,18 @@ <div class="dashboard-container" id="user-dashboard"> <%= render "actions" %> - <% if @user.org_admin && @user.organization %> + <% if @organizations && (params[:which].blank? || params[:which] == "organization") %> <h1> - <a href="/dashboard/user_followers" class="rounded-btn <%= "active" if params[:which] == "user_followers" %>">Personal</a> - <a href="/dashboard/organization_user_followers" class="rounded-btn <%= "active" if params[:which] == "organization_user_followers" %>"><%= @user.organization.name %> (<%= @user.organization.followers_count %>)</a> - <% if @user.has_role? :pro %> - <a class="rounded-btn" href="/dashboard/pro">Pro Analytics</a> + <a href="/dashboard" class="rounded-btn <%= "active" if params[:which].blank? %>">Personal</a> + <% @organizations.each do |org| %> + <a href="/dashboard/organization/<%= org.id %>" class="rounded-btn <%= "active" if params[:org_id].to_i == org.id %>"><%= org.name %> (<%= org.articles.size %>)</a> <% end %> - <% if @user.has_role?(:pro) && @user.organization_id %> - <a class="rounded-btn" href="/dashboard/pro/org/<%= @user.organization_id %>">Pro Analytics for <%= @user.organization.name %></a> + <% if @user.has_role? :pro %> + <br> + <a class="rounded-btn" href="/dashboard/pro">Pro Analytics</a> + <% @organizations.each do |org| %> + <a class="rounded-btn" href="/dashboard/pro/org/<%= org.id %>">Pro Analytics for <%= org.name %></a> + <% end %> <% end %> </h1> <% end %> diff --git a/app/views/dashboards/pro.html.erb b/app/views/dashboards/pro.html.erb index 69c5e3868..507c3b89c 100644 --- a/app/views/dashboards/pro.html.erb +++ b/app/views/dashboards/pro.html.erb @@ -1,17 +1,15 @@ <div class="dashboard-container pro-container" id="user-dashboard"> - <a href="/dashboard" class="rounded-btn">Back to Dashboard</a> - <% current_active_view = @dashboard.user_or_org.class.name %> - <% if current_active_view == "Organization" %> - <a class="rounded-btn" href="/dashboard/pro"> - View Your Pro Dashboard + <a href="/dashboard" class="rounded-btn">Back to Main Dashboard</a> + <a class="rounded-btn <%= "active" if params[:org_id].blank? %>" href="/dashboard/pro"> + Your Pro Dashboard </a> - <% elsif current_active_view == "User" && @dashboard.user_or_org.organization_id %> - <a class="rounded-btn" href="/dashboard/pro/org/<%= @dashboard.user_or_org.organization_id %>"> - View <%= @dashboard.user_or_org.organization.name %> Dashboard - </a> - <% end %> + <% @organizations.each do |org| %> + <a class="rounded-btn <%= "active" if params[:org_id].to_i == org.id %>" href="/dashboard/pro/org/<%= org.id %>"> + <%= org.name %> Pro Dashboard + </a> + <% end %> <section class="header-card card"> - <h1 class="pro-header">Pro Dashboard <%= "for #{@dashboard.user_or_org.name}" if current_active_view == "Organization" %></h1> + <h1 class="pro-header">Pro Dashboard <%= "for #{@dashboard.user_or_org.name}" %></h1> <p>Welcome to the pro dashboard which shows in-depth user metrics so that authors can make data-driven decisions about the developer ecosystem.</p> <p>This dashboard will highlight deep insights especially relevant to developer relations authors and serious bloggers.</p> </section> diff --git a/app/views/dashboards/show.html.erb b/app/views/dashboards/show.html.erb index 3635c5e83..11f34e6dc 100644 --- a/app/views/dashboards/show.html.erb +++ b/app/views/dashboards/show.html.erb @@ -2,22 +2,25 @@ <div class="dashboard-container" id="user-dashboard"> <%= render "actions" %> - <% if @user.org_admin && @user.organization && (params[:which].blank? || params[:which] == "organization") %> + <% if @organizations && (params[:which].blank? || params[:which] == "organization") %> <h1> <a href="/dashboard" class="rounded-btn <%= "active" if params[:which].blank? %>">Personal</a> - <a href="/dashboard/organization" class="rounded-btn <%= "active" if params[:which] == "organization" %>"><%= @user.organization.name %> (<%= @user.organization.articles.size %>)</a> - <% if @user.has_role? :pro %> - <a class="rounded-btn" href="/dashboard/pro">Pro Analytics</a> + <% @organizations.each do |org| %> + <a href="/dashboard/organization/<%= org.id %>" class="rounded-btn <%= "active" if params[:org_id].to_i == org.id %>"><%= org.name %> (<%= org.articles_count %>)</a> <% end %> - <% if @user.has_role?(:pro) && @user.organization_id %> - <a class="rounded-btn" href="/dashboard/pro/org/<%= @user.organization_id %>">Pro Analytics for <%= @user.organization.name %></a> + <% if @user.has_role? :pro %> + <hr> + <a class="rounded-btn" href="/dashboard/pro">Pro Analytics</a> + <% @member_organizations.each do |org| %> + <a class="rounded-btn" href="/dashboard/pro/org/<%= org.id %>"><%= org.name %></a> + <% end %> <% end %> </h1> <% end %> - <% if @org_dashboard %> + <% if params[:which] == "organization" %> <%= render "analytics" %> <% @articles.each do |article| %> - <%= render "dashboard_article", article: article, org_admin: true, manage_view: false %> + <%= render "dashboard_article", article: article, organization: article.organization, org_admin: true, manage_view: false %> <% end %> <% elsif @articles.any? %> <%= render "analytics" %> @@ -33,7 +36,7 @@ <% end %> </div> <% @articles.each do |article| %> - <%= render "dashboard_article", article: article, org_admin: false, manage_view: false %> + <%= render "dashboard_article", article: article, organization: nil, org_admin: false, manage_view: false %> <% end %> <% else %> <div class="no-articles"> diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index c93e307e9..74b2983f7 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -32,15 +32,17 @@ <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].blank? %>" href="/notifications/comments">COMMENTS</a> <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("posts").zero? && params[:org_id].blank? %>" href="/notifications/posts">POSTS</a> </div> - <% if user_signed_in? && current_user.organization_id.present? %> - <hr> - <header> - <h4><%= current_user.organization.name %> <%= "(#{@total_org_unread})" if @total_org_unread.positive? %></h4> - </header> - <div class="widget-body"> - <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("org").zero? && params[:org_id].to_i == current_user.organization_id %>" href="/notifications/org/<%= current_user.organization_id %>">ALL</a> - <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].to_i == current_user.organization_id %>" href="/notifications/comments/<%= current_user.organization_id %>">COMMENTS</a> - </div> + <% if @organizations.present? %> + <% @organizations.each do |org| %> + <hr> + <header> + <h4><%= org.name %> <%= "(#{org.notifications.where(read: false).size})" if org.notifications.where(read: false).size.positive? %></h4> + </header> + <div class="widget-body"> + <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("org").zero? && params[:org_id].to_i == org.id %>" href="/notifications/org/<%= org.id %>">ALL</a> + <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].to_i == org.id %>" href="/notifications/comments/<%= org.id %>">COMMENTS</a> + </div> + <% end %> <% end %> </div> </div> @@ -50,13 +52,15 @@ <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].blank? %>" href="/notifications/comments">COMMENTS</a> <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("posts").zero? && params[:org_id].blank? %>" href="/notifications/posts">POSTS</a> </div> - <% if current_user.organization_id.present? %> - <div class="notifications-mobile-filters"> - <hr> - <span class="organization-name"><%= current_user.organization.name %> <%= "(#{@total_org_unread})" if @total_org_unread.positive? %></span> - <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("org").zero? && params[:org_id].to_i == current_user.organization_id %>" href="/notifications/org/<%= current_user.organization_id %>">ALL</a> - <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].to_i == current_user.organization_id %>" href="/notifications/comments/<%= current_user.organization_id %>">COMMENTS</a> - </div> + <% if @organizations.present? %> + <% @organizations.each do |org| %> + <div class="notifications-mobile-filters"> + <hr> + <span class="organization-name"><%= org.name %> <%= "(#{org.notifications.where(read: false).size})" if org.notifications.where(read: false).size.positive? %></span> + <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("org").zero? && params[:org_id].to_i == org.id %>" href="/notifications/org/<%= org.id %>">ALL</a> + <a class="query-filter-button <%= "selected" if params[:filter].to_s.casecmp("comments").zero? && params[:org_id].to_i == org.id %>" href="/notifications/comments/<%= org.id %>">COMMENTS</a> + </div> + <% end %> <% end %> <%= render "notifications_list" %> <% if @notifications.any? %> @@ -69,5 +73,3 @@ <% else %> <%= render "devise/registrations/registration_form" %> <% end %> - - diff --git a/app/views/users/_org_admin.html.erb b/app/views/users/_org_admin.html.erb index 40ba15e11..a65acc9cc 100644 --- a/app/views/users/_org_admin.html.erb +++ b/app/views/users/_org_admin.html.erb @@ -17,27 +17,30 @@ </style> <div class="settings-org-members"> <h3>Organization Members</h3> - <% @organization.users.each do |user| %> - <div class="settings-org-member-row <%= "settings-org-admin-row" if user.org_admin %>"> - <a href="<%= user.path %>"><%= user.name %></a> + <% @org_organization_memberships.each do |org_membership| %> + <div class="settings-org-member-row <%= "settings-org-admin-row" if org_membership.type_of_user == "admin" %>"> + <a href="<%= org_membership.user.path %>"><%= org_membership.user.name %></a> <div class="settings-org-member-actions"> - <% if user.org_admin && user == current_user %> + <% if org_membership.user == @user && org_membership.type_of_user == "admin" %> <button class="settings-org-member settings-org-member-admin-self">you (admin)</button> - <% elsif user.org_admin && user != current_user %> + <% elsif org_membership.user != @user && org_membership.type_of_user == "admin" %> <button class="settings-org-member settings-org-member-admin-other">admin</button> - <%= form_tag "/users/remove_org_admin", onsubmit: "return confirm('Are you sure you want to take away admin status from #{user.name}?');" do %> - <%= hidden_field_tag "user_id", user.id %> - <button class="settings-org-member settings-org-member-remove" title="Remove from org">revoke admin status</button> + <%= form_tag "/users/remove_org_admin", onsubmit: "return confirm('Are you sure you want to take away admin status from #{org_membership.user.name}?');" do %> + <%= hidden_field_tag "user_id", org_membership.user_id %> + <%= hidden_field_tag "organization_id", @organization.id %> + <button class="settings-org-member settings-org-member-remove" title="Revoke admin status" type="submit">revoke admin status</button> <% end %> <% else %> <button class="settings-org-member settings-org-member-admin-self">org member</button> - <%= form_tag "/users/add_org_admin", onsubmit: "return confirm('Are you sure you want to give #{user.name} admin status?');" do %> - <%= hidden_field_tag "user_id", user.id %> - <button class="settings-org-member settings-org-member-make-admin">make admin</button> + <%= form_tag "/users/add_org_admin", onsubmit: "return confirm('Are you sure you want to give #{org_membership.user.name} admin status?');" do %> + <%= hidden_field_tag "user_id", org_membership.user_id %> + <%= hidden_field_tag "organization_id", @organization.id %> + <button class="settings-org-member settings-org-member-make-admin" title="Make admin" type="submit">make admin</button> <% end %> - <%= form_tag "/users/remove_from_org", onsubmit: "return confirm('Are you sure you want remove #{user.name} from the organization?');" do %> - <%= hidden_field_tag "user_id", user.id %> - <button class="settings-org-member settings-org-member-remove" title="Remove from org">remove from org</button> + <%= form_tag "/users/remove_from_org", onsubmit: "return confirm('Are you sure you want remove #{org_membership.user.name} from the organization?');" do %> + <%= hidden_field_tag "user_id", org_membership.user_id %> + <%= hidden_field_tag "organization_id", @organization.id %> + <button class="settings-org-member settings-org-member-remove" title="Remove from org" type="submit">remove from org</button> <% end %> <% end %> </div> @@ -56,11 +59,13 @@ <h5>Your secret: (You should rotate this regularly)</h5> <input type="text" class="settings-org-secret" id="settings-org-secret" value="<%= @organization.secret %>" readonly aria-label="Organization secret (to be rotated regularly)"> <%= form_tag "/organizations/generate_new_secret", onsubmit: "return confirm('Are you sure you want to generate a new secret? All outstanding secrets will be invalid.');" do %> + <%= hidden_field_tag "organization[id]", @organization.id %> <button class="settings-generate-new-secret">Generate New Secret</button> <% end %> </div> <h3>Organization Details</h3> <%= form_for(@organization) do |f| %> +<%= f.hidden_field :id %> <div class="field"> <%= f.label :name %> <%= f.text_field :name, maxlength: 50 %> diff --git a/app/views/users/_org_guest.html.erb b/app/views/users/_org_guest.html.erb new file mode 100644 index 000000000..d5e03786a --- /dev/null +++ b/app/views/users/_org_guest.html.erb @@ -0,0 +1 @@ +<%= render "org_member" %> diff --git a/app/views/users/_org_member.html.erb b/app/views/users/_org_member.html.erb index 16edc5270..72470063e 100644 --- a/app/views/users/_org_member.html.erb +++ b/app/views/users/_org_member.html.erb @@ -1,6 +1,6 @@ <div class="settings-org-members"> - <h3><%= @user.organization.name %></h3> - <% @user.organization.users.each do |user| %> + <h3><%= @organization.name %></h3> + <% @organization.users.each do |user| %> <div class="settings-org-member-row"> <a href="<%= user.path %>"><%= user.name %></a> </div> diff --git a/app/views/users/_organization.html.erb b/app/views/users/_organization.html.erb index 5a791588b..2ec9d0bf7 100644 --- a/app/views/users/_organization.html.erb +++ b/app/views/users/_organization.html.erb @@ -1,7 +1,5 @@ -<% if @user.organization && @user.org_admin %> - <%= render "users/org_admin" %> -<% elsif @user.organization %> - <%= render "users/org_member" %> +<% if @organization_membership %> + <%= render "users/org_#{@organization_membership.type_of_user}" %> <% else %> <%= render "users/org_non_member" %> <% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index b94f3806d..ca3be3563 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -1,8 +1,8 @@ <% title "Settings" %> -<% if flash[:notice] && flash[:notice].include?("Your ") %> +<% if flash[:settings_notice] %> <div class="notice" id="notice"> - <%= flash[:notice] %> + <%= flash[:settings_notice] %> </div> <% end %> @@ -20,8 +20,21 @@ <% end %> <div class="user-settings-page"> - <% if @tab == "organization" && @user.organization.present? %> - <h2>Settings for <a href="/<%= @user.organization.slug %>">@<%= @user.organization.slug %></a></h2> + <% if @tab == "organization" && @organizations.present? %> + <h2>Settings for + <span class="organization-dropdown"> + <select id="organization-dropdown-select" onchange="window.location.href=this.value"> + <% options = @organizations.pluck(:slug, :id).each do |arr| %> + <% arr[0] = "@#{arr[0]}" %> + <% arr[1] = "/settings/organization/#{arr[1]}" %> + <% end.to_h.merge("New Organization" => "/settings/organization/new") %> + <%= options_for_select(options, "/settings/organization/#{params[:org_id]}") %> + <%# For example: %> + <%# <option value="/settings/organization/1>@devteam</option> %> + <%# <option value="/settings/organization/new" selected>New Organization</option> %> + </select> + </span> + </h2> <% else %> <h2>Settings for <a href="/<%= @user.username %>">@<%= @user.username %></a></h2> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 5b670e6fe..500e01082 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -198,7 +198,7 @@ Rails.application.routes.draw do post "users/update_language_settings" => "users#update_language_settings" post "users/update_twitch_username" => "users#update_twitch_username" post "users/join_org" => "users#join_org" - post "users/leave_org" => "users#leave_org" + post "users/leave_org/:organization_id" => "users#leave_org" post "users/add_org_admin" => "users#add_org_admin" post "users/remove_org_admin" => "users#remove_org_admin" post "users/remove_from_org" => "users#remove_from_org" @@ -266,6 +266,7 @@ Rails.application.routes.draw do end get "/settings/(:tab)" => "users#edit" + get "/settings/:tab/:org_id" => "users#edit" get "/signout_confirm" => "users#signout_confirm" get "/dashboard" => "dashboards#show" get "/dashboard/pro" => "dashboards#pro" @@ -275,9 +276,9 @@ Rails.application.routes.draw do constraints: { which: /organization_user_followers|user_followers/ } - get "/dashboard/:which" => "dashboards#show", + get "/dashboard/:which/:org_id" => "dashboards#show", constraints: { - which: /organization|reading/ + which: /organization/ } get "/dashboard/:username" => "dashboards#show" diff --git a/db/migrate/20190521190118_add_articles_count_to_organizations.rb b/db/migrate/20190521190118_add_articles_count_to_organizations.rb new file mode 100644 index 000000000..a1e6e8ed1 --- /dev/null +++ b/db/migrate/20190521190118_add_articles_count_to_organizations.rb @@ -0,0 +1,5 @@ +class AddArticlesCountToOrganizations < ActiveRecord::Migration[5.2] + def change + add_column :organizations, :articles_count, :integer, default: 0, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index cb595cdae..3a44761d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_05_04_131412) do +ActiveRecord::Schema.define(version: 2019_05_21_190118) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -538,6 +538,7 @@ ActiveRecord::Schema.define(version: 2019_05_04_131412) do create_table "organizations", id: :serial, force: :cascade do |t| t.string "address" t.boolean "approved", default: false + t.integer "articles_count", default: 0, null: false t.string "bg_color_hex" t.string "city" t.string "company_size" diff --git a/spec/factories/credits.rb b/spec/factories/credits.rb index 876d93b6a..00150684c 100644 --- a/spec/factories/credits.rb +++ b/spec/factories/credits.rb @@ -1,4 +1,7 @@ FactoryBot.define do factory :credit do + # schema defaults + # cost 0.0 + # spent false end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index cb58ddb09..a996fab88 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -23,6 +23,14 @@ FactoryBot.define do signup_cta_variant { "navbar_basic" } email_digest_periodic { false } + after(:create) do |user| + create(:identity, user_id: user.id) + end + + trait :two_identities do + after(:create) { |user| create(:identity, user_id: user.id, provider: "twitter") } + end + trait :super_admin do after(:build) { |user| user.add_role(:super_admin) } end @@ -55,28 +63,19 @@ FactoryBot.define do end trait :org_member do - after(:build) do |user| + after(:create) do |user| org = create(:organization) - user.organization_id = org.id + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "member") end end trait :org_admin do - after(:build) do |user| + after(:create) do |user| org = create(:organization) - user.organization_id = org.id - user.org_admin = true + create(:organization_membership, user_id: user.id, organization_id: org.id, type_of_user: "admin") end end - after(:create) do |user| - create(:identity, user_id: user.id) - end - - trait :two_identities do - after(:create) { |user| create(:identity, user_id: user.id, provider: "twitter") } - end - trait :with_article do after(:create) do |user| create(:article, user_id: user.id) diff --git a/spec/models/classified_listing_spec.rb b/spec/models/classified_listing_spec.rb index c9a6756a2..be384a95f 100644 --- a/spec/models/classified_listing_spec.rb +++ b/spec/models/classified_listing_spec.rb @@ -3,6 +3,7 @@ require "rails_helper" RSpec.describe ClassifiedListing, type: :model do let(:classified_listing) { create(:classified_listing, user_id: user.id) } let(:user) { create(:user) } + let(:organization) { create(:organization) } it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:body_markdown) } @@ -21,7 +22,7 @@ RSpec.describe ClassifiedListing, type: :model do end it "is valid with organization_id" do - cl = build(:classified_listing, user_id: nil, organization_id: create(:organization).id) + cl = build(:classified_listing, user_id: nil, organization_id: organization.id) expect(cl).to be_valid end end diff --git a/spec/models/credit_spec.rb b/spec/models/credit_spec.rb index 403315920..01085277b 100644 --- a/spec/models/credit_spec.rb +++ b/spec/models/credit_spec.rb @@ -1,15 +1,39 @@ require "rails_helper" RSpec.describe Credit, type: :model do - it "counts credits for user" do - user = create(:user) - Credit.create(user_id: user.id) - Credit.create(user_id: user.id) - expect(user.reload.credits_count).to eq(2) + let(:user) { create(:user) } + let(:organization) { create(:organization) } + let(:random_number) { rand(100) } + + xit "counts credits for user" do + # See https://github.com/magnusvk/counter_culture/issues/259 + create_list(:credit, random_number, user: user) + expect(user.reload.credits_count).to eq(random_number) end - it "counts credits for organization" do - organization = create(:organization) - Credit.create(organization_id: organization.id) - expect(organization.reload.credits_count).to eq(1) + + xit "counts credits for organization" do + # See https://github.com/magnusvk/counter_culture/issues/259 + create_list(:credit, random_number, organization: organization) + expect(organization.reload.credits_count).to eq(random_number) + end + + it "counts the number of unspent credits for a user" do + create_list(:credit, random_number, user: user) + expect(user.reload.unspent_credits_count).to eq(random_number) + end + + it "counts the number of spent credits for a user" do + create_list(:credit, random_number, user: user, spent: true) + expect(user.reload.spent_credits_count).to eq(random_number) + end + + it "counts the number of unspent credits for an organization" do + create_list(:credit, random_number, organization: organization) + expect(organization.reload.unspent_credits_count).to eq(random_number) + end + + it "counts the number of spent credits for an organization" do + create_list(:credit, random_number, organization: organization, spent: true) + expect(organization.reload.spent_credits_count).to eq(random_number) end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index cc2ee413e..2885110a2 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -111,7 +111,7 @@ RSpec.describe Notification, type: :model do it "sends a notification to the organization" do org = create(:organization) - user.update(organization: org) + create(:organization_membership, user: user, organization: org) article.update(organization: org) comment = create(:comment, user: user2, commentable: article) Notification.send_new_comment_notifications_without_delay(comment) @@ -231,7 +231,7 @@ RSpec.describe Notification, type: :model do let(:org) { create(:organization) } before do - user.update(organization: org, org_admin: true) + create(:organization_membership, user: user, organization: org, type_of_user: "admin") article.update(organization: org) end diff --git a/spec/models/organization_membership_spec.rb b/spec/models/organization_membership_spec.rb index c9cb0ce0c..53eef4469 100644 --- a/spec/models/organization_membership_spec.rb +++ b/spec/models/organization_membership_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" RSpec.describe OrganizationMembership, type: :model do describe "validations" do - subject { create(:organization_membership) } + subject { build(:organization_membership) } it { is_expected.to validate_presence_of(:user_id) } it { is_expected.to validate_presence_of(:organization_id) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c1e538571..caa934ae5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -643,12 +643,12 @@ RSpec.describe User, type: :model do describe "organization admin privileges" do it "recognizes an org admin" do - user.update(organization: org, org_admin: true) + create(:organization_membership, user: user, organization: org, type_of_user: "admin") expect(user.org_admin?(org)).to be true end it "forbids an incorrect org admin" do - user.update(organization: org, org_admin: true) + create(:organization_membership, user: user, organization: org, type_of_user: "admin") expect(user.org_admin?(second_org)).to be false expect(second_user.org_admin?(org)).to be false end diff --git a/spec/policies/organization_policy_spec.rb b/spec/policies/organization_policy_spec.rb index deb18b1e2..e44282399 100644 --- a/spec/policies/organization_policy_spec.rb +++ b/spec/policies/organization_policy_spec.rb @@ -25,9 +25,12 @@ RSpec.describe OrganizationPolicy do end context "when user is an org admin of an org" do - let(:user) { build(:user) } + subject(:organization_policy) { described_class.new(user, org) } - before { user.update(organization: organization, org_admin: true) } + let(:user) { create(:user) } + let(:org) { create(:organization) } + + before { create(:organization_membership, user: user, organization: org, type_of_user: "admin") } it "allows the user to update their own org" do expect(organization_policy).to permit_action(:update) @@ -35,10 +38,13 @@ RSpec.describe OrganizationPolicy do end context "when user is an org admin of another org" do - let(:user) { build(:user) } - let(:new_org) { build(:organization) } + subject(:organization_policy) { described_class.new(user, new_org) } - before { user.update(organization: new_org, org_admin: true) } + let(:user) { create(:user) } + let(:org) { create(:organization) } + let(:new_org) { create(:organization) } + + before { create(:organization_membership, user: user, organization: org, type_of_user: "admin") } it "does not allow the user to update another org" do expect(organization_policy).to forbid_action(:update) diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 7f5611262..3c4490e5a 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -15,7 +15,7 @@ RSpec.describe UserPolicy do let(:user) { other_user } permitted_actions = %i[ - edit update onboarding_update join_org leave_org dashboard_show remove_association destroy + edit update onboarding_update join_org dashboard_show remove_association destroy ] it { is_expected.to permit_actions(permitted_actions) } @@ -27,36 +27,6 @@ RSpec.describe UserPolicy do end end - context "when user is org_admin" do - let(:org) { build(:organization) } - let(:other_org) { build(:organization) } - let(:user) { build(:user, org_admin: true, organization: org) } - - context "with other_user as org_member of same org" do - let(:other_user) { build(:user, organization: org) } - - it { is_expected.to permit_actions(%i[add_org_admin remove_from_org]) } - end - - context "with other_user as org_member of a different org" do - let(:other_user) { build(:user, organization: other_org) } - - it { is_expected.to forbid_actions(%i[add_org_admin remove_from_org]) } - end - - context "with other_user as org admin" do - let(:other_user) { build(:user, org_admin: true, organization: org) } - - it { is_expected.to permit_actions(%i[remove_org_admin]) } - end - - context "with other_user as org adming of a different org" do - let(:other_user) { build(:user, org_admin: true, organization: other_org) } - - it { is_expected.to forbid_actions(%i[remove_org_admin]) } - end - end - context "when user is trusted" do let(:user) { build(:user, :trusted) } diff --git a/spec/requests/api/v0/articles_spec.rb b/spec/requests/api/v0/articles_spec.rb index 2c2288967..e51993218 100644 --- a/spec/requests/api/v0/articles_spec.rb +++ b/spec/requests/api/v0/articles_spec.rb @@ -258,11 +258,11 @@ RSpec.describe "Api::V0::Articles", type: :request do end it "creates an article on behalf of an organization" do - user.update_columns(organization_id: organization.id) + create(:organization_membership, user: user, organization: organization) expect do post_article( title: Faker::Book.title + rand(100).to_s, - publish_under_org: true, + organization_id: organization.id, ) expect(response).to have_http_status(:created) end.to change(Article, :count).by(1) @@ -593,8 +593,8 @@ RSpec.describe "Api::V0::Articles", type: :request do it "assigns the article to the organization" do expect(article.organization).to be_nil - user.update_columns(organization_id: organization.id) - put_article(publish_under_org: true) + create(:organization_membership, user: user, organization: organization) + put_article(organization_id: organization.id) expect(response).to have_http_status(:ok) expect(article.reload.organization).to eq(organization) end diff --git a/spec/requests/articles_create_spec.rb b/spec/requests/articles_create_spec.rb index f72d2138a..b7735a127 100644 --- a/spec/requests/articles_create_spec.rb +++ b/spec/requests/articles_create_spec.rb @@ -1,8 +1,7 @@ require "rails_helper" RSpec.describe "ArticlesCreate", type: :request do - let(:organization) { create(:organization) } - let(:user) { create(:user, organization_id: organization.id) } + let(:user) { create(:user, :org_member) } before do sign_in user @@ -27,14 +26,15 @@ RSpec.describe "ArticlesCreate", type: :request do end it "creates article with front matter params and org" do + user_org_id = user.organizations.first.id post "/articles", params: { article: { body_markdown: "---\ntitle: hey hey hahuu\npublished: false\n---\nYo ho ho#{rand(100)}", tag_list: "yo", - post_under_org: true + organization_id: user_org_id } } - expect(Article.last.organization_id).to eq(organization.id) + expect(Article.last.organization_id).to eq(user_org_id) end it "creates series when series is created with frontmatter" do diff --git a/spec/requests/articles_update_spec.rb b/spec/requests/articles_update_spec.rb index b464cf024..40bb11941 100644 --- a/spec/requests/articles_update_spec.rb +++ b/spec/requests/articles_update_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe "ArticlesUpdate", type: :request do let(:organization) { create(:organization) } let(:organization2) { create(:organization) } - let(:user) { create(:user, organization_id: organization.id) } + let(:user) { create(:user, :org_admin) } let(:user2) { create(:user, organization_id: organization2.id) } let(:article) { create(:article, user_id: user.id) } @@ -32,16 +32,18 @@ RSpec.describe "ArticlesUpdate", type: :request do end it "adds organization ID when user updates" do + user_org_id = user.organizations.first.id put "/articles/#{article.id}", params: { - article: { post_under_org: true } + article: { organization_id: user_org_id } } - expect(article.reload.organization_id).to eq organization.id + expect(article.reload.organization_id).to eq user_org_id end it "removes organization ID when user updates" do - article.update_column(:organization_id, organization.id) + article.update_column(:organization_id, user.organizations.first.id) put "/articles/#{article.id}", params: { - article: { post_under_org: false } + # use empty string instead of nil to mock article form submission + article: { organization_id: "" } } expect(article.reload.organization_id).to eq nil end @@ -64,12 +66,13 @@ RSpec.describe "ArticlesUpdate", type: :request do end it "allows an org admin to assign an org article to another user" do - user.update_columns(org_admin: true) - article.update_columns(organization_id: user.organization_id) - other_user = create(:user, organization: user.organization) + admin_org_id = user.organizations.first.id + article.update_columns(organization_id: admin_org_id) + other_user = create(:user) + create(:organization_membership, user_id: other_user.id, organization_id: admin_org_id) put "/articles/#{article.id}", params: { article: { user_id: other_user.id } } expect(article.reload.user).to eq(other_user) - expect(article.organization).to eq(user.organization) + expect(article.organization_id).to eq(admin_org_id) end end diff --git a/spec/requests/classified_listings_spec.rb b/spec/requests/classified_listings_spec.rb index ae786904d..7eedadbc5 100644 --- a/spec/requests/classified_listings_spec.rb +++ b/spec/requests/classified_listings_spec.rb @@ -8,18 +8,72 @@ RSpec.describe "ClassifiedListings", type: :request do title: "something", body_markdown: "something else", category: "cfp", - tag_list: "", - post_as_organization: "0" + tag_list: "" } } end - before do - sign_in user - create_list(:credit, 25, user: user) + describe "GET /listings/new" do + before { sign_in user } + + context "when the user has no credits" do + it "shows the proper messages" do + get "/listings/new" + expect(response.body).to include "Listings Require Credits" + expect(response.body).to include "You need at least one credit to create a listing." + end + end + + context "when the user has credits" do + it "shows the number of credits" do + random_number = rand(2..100) + create_list(:credit, random_number, user: user) + get "/listings/new" + expect(response.body).to include "You have #{random_number} credits available" + end + end + + context "when the user has no credits and belongs to an organization" do + let(:organization) { create(:organization) } + + before { create(:organization_membership, user_id: user.id, organization_id: organization.id) } + + it "shows the proper message when both user and org have no credits" do + get "/listings/new" + expect(response.body).to include "Listings Require Credits" + end + + it "shows the number of credits of the user if the user has credits but the org has no credits" do + random_number = rand(2..100) + create_list(:credit, random_number, user: user) + get "/listings/new" + expect(response.body).to include "You have #{random_number} credits available" + end + + it "shows the number of credits of the organization if the org has credits" do + random_number = rand(2..100) + create_list(:credit, random_number, organization: organization) + get "/listings/new" + expect(response.body).to include "has <span id=\"org-credits-number\">#{random_number}</span> credits" + end + + it "shows the number of credits of both the user and the organization if they both have credits" do + random_number = rand(2..100) + create_list(:credit, random_number, organization: organization) + create_list(:credit, random_number, user: user) + get "/listings/new" + expect(response.body).to include "has <span id=\"org-credits-number\">#{random_number}</span> credits" + expect(response.body).to include "You have #{random_number} credits available" + end + end end describe "POST /listings" do + before do + sign_in user + create_list(:credit, 25, user: user) + end + context "when the listing is invalid" do it "renders errors with the listing" do post "/listings", params: { @@ -27,8 +81,7 @@ RSpec.describe "ClassifiedListings", type: :request do title: "nothing", body_markdown: "", category: "cfp", - tag_list: "", - post_as_organization: "0" + tag_list: "" } } expect(response.body).to include("prohibited this listing from being saved") @@ -62,11 +115,12 @@ RSpec.describe "ClassifiedListings", type: :request do it "creates a listing under the org" do org_admin = create(:user, :org_admin) - Credit.create(organization_id: org_admin.organization_id) - valid_listing_params[:classified_listing][:post_as_organization] = "1" + org_id = org_admin.organizations.first.id + Credit.create(organization_id: org_id) + valid_listing_params[:classified_listing][:organization_id] = org_id sign_in org_admin post "/listings", params: valid_listing_params - expect(ClassifiedListing.first.organization_id).to eq org_admin.organization_id + expect(ClassifiedListing.first.organization_id).to eq org_id end end end diff --git a/spec/requests/credits_spec.rb b/spec/requests/credits_spec.rb index 99df978bd..a72fe4814 100644 --- a/spec/requests/credits_spec.rb +++ b/spec/requests/credits_spec.rb @@ -3,32 +3,35 @@ require "rails_helper" RSpec.describe "Credits", type: :request do describe "GET /credits" do let(:user) { create(:user) } - let(:organization) { create(:organization) } - - before do - sign_in user - end + let(:org_member) { create(:user, :org_member) } + let(:org_admin) { create(:user, :org_admin) } it "shows credits page" do + sign_in user get "/credits" expect(response.body).to include("You have") end it "shows credits page if user belongs to an org" do - user.update_column(:organization_id, organization.id) + org = org_member.organizations.first + sign_in org_member get "/credits" expect(response.body).to include("You have") + expect(response.body).not_to include(CGI.escapeHTML(org.name)) end it "shows credits page if user belongs to an org and is org admin" do - user.update_columns(organization_id: organization.id, org_admin: true) + org = org_admin.organizations.first + sign_in org_admin get "/credits" - expect(response.body).to include(CGI.escapeHTML(organization.name)) + expect(response.body).to include(CGI.escapeHTML(org.name)) end end describe "POST credits" do let(:user) { create(:user) } + let(:org_admin) { create(:user, :org_admin) } + let(:admin_org_id) { org_admin.organizations.first.id } let(:stripe_helper) { StripeMock.create_test_helper } before do @@ -104,24 +107,22 @@ RSpec.describe "Credits", type: :request do end context "when purchasing as an organization" do - let(:org_admin) { create(:user, :org_admin) } - before { sign_in org_admin } it "creates unspent credits for the organization" do post "/credits", params: { - user_type: "organization", + organization_id: admin_org_id, credit: { number_to_purchase: 20 }, stripe_token: stripe_helper.generate_card_token } - expect(Credit.where(organization_id: org_admin.organization_id, spent: false).size).to eq 20 + expect(Credit.where(organization_id: admin_org_id, spent: false).size).to eq 20 end it "makes a valid Stripe charge" do post "/credits", params: { - user_type: "organization", + organization_id: admin_org_id, credit: { number_to_purchase: 20 }, @@ -133,7 +134,7 @@ RSpec.describe "Credits", type: :request do it "does not create unspent credits for the current_user" do post "/credits", params: { - user_type: "organization", + organization_id: admin_org_id, credit: { number_to_purchase: 20 }, @@ -154,6 +155,19 @@ RSpec.describe "Credits", type: :request do } expect(user.credits.where(spent: false).size).to eq(0) end + + it "does not reward credits for orgs" do + sign_in org_admin + StripeMock.prepare_error(Stripe::CardError.new(2, 3, 4), :new_charge) + post "/credits", params: { + organization_id: admin_org_id, + credit: { + number_to_purchase: 25 + }, + stripe_token: stripe_helper.generate_card_token + } + expect(Credit.where(organization_id: admin_org_id, spent: false).size).to eq(0) + end end end end diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index cf11cef90..745ffdf32 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -3,7 +3,6 @@ require "rails_helper" RSpec.describe "Dashboards", type: :request do let(:user) { create(:user) } let(:second_user) { create(:user) } - let(:org_admin) { create(:user, :org_admin) } let(:super_admin) { create(:user, :super_admin) } let(:article) { create(:article, user_id: user.id) } @@ -47,7 +46,7 @@ RSpec.describe "Dashboards", type: :request do context "when logged in" do it "renders user's organization articles" do - user.update(organization_id: organization.id, org_admin: true) + create(:organization_membership, user: user, organization: organization, type_of_user: "admin") article.update(organization_id: organization.id) sign_in user get "/dashboard/organization" @@ -164,9 +163,11 @@ RSpec.describe "Dashboards", type: :request do context "when user has pro permission and is an org admin" do it "shows page properly" do - org_admin.add_role(:pro) - sign_in org_admin - get "/dashboard/pro/org/#{org_admin.organization_id}" + org = create :organization + create(:organization_membership, user: user, organization: org, type_of_user: "admin") + user.add_role(:pro) + login_as user + get "/dashboard/pro/org/#{org.id}" expect(response.body).to include("pro") end end @@ -174,7 +175,7 @@ RSpec.describe "Dashboards", type: :request do context "when user has pro permission and is an org member" do it "shows page properly" do org = create :organization - user.update(organization_id: org.id) + create(:organization_membership, user: user, organization: org) user.add_role(:pro) sign_in user get "/dashboard/pro/org/#{org.id}" diff --git a/spec/requests/listings_spec.rb b/spec/requests/listings_spec.rb index 9db87bc03..7266ce893 100644 --- a/spec/requests/listings_spec.rb +++ b/spec/requests/listings_spec.rb @@ -29,40 +29,60 @@ RSpec.describe "/listings", type: :request do end it "creates proper listing if credits are available" do - post "/listings", params: { classified_listing: { - title: "Hey", category: "education", body_markdown: "hey hey my my" - } } + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my" } + } expect(ClassifiedListing.last.processed_html).to include("hey my") end + it "spends credits" do num_credits = Credit.where(spent: true).size - post "/listings", params: { classified_listing: { - title: "Hey", category: "education", body_markdown: "hey hey my my" - } } + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my" } + } expect(Credit.where(spent: true).size).to be > num_credits end + it "adds tags" do - post "/listings", params: { classified_listing: { - title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go" - } } + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go" } + } expect(ClassifiedListing.last.cached_tag_list).to include("rails") end - it "creates the listing for the user" do - user.update_column(:organization_id, organization.id) - post "/listings", params: { classified_listing: { - title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go", - post_as_organization: 0 - } } - expect(ClassifiedListing.last.organization_id).not_to eq(organization.id) + + it "creates the listing under the user" do + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go" } + } + expect(ClassifiedListing.last.user_id).to eq user.id end + + it "creates the listing for the user if no organization_id is selected" do + create(:organization_membership, user_id: user.id, organization_id: organization.id) + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go" } + } + expect(ClassifiedListing.last.organization_id).to eq nil + expect(ClassifiedListing.last.user_id).to eq user.id + end + it "creates the listing for the organization" do - user.update_column(:organization_id, organization.id) - post "/listings", params: { classified_listing: { - title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go", - post_as_organization: 1 - } } + create(:organization_membership, user_id: user.id, organization_id: organization.id) + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go", organization_id: organization.id } + } expect(ClassifiedListing.last.organization_id).to eq(organization.id) end + + it "does not create an org listing if a different org member doesn't belong to the org" do + another_org = create(:organization) + create(:organization_membership, user_id: user.id, organization_id: another_org.id) + expect do + post "/listings", params: { + classified_listing: { title: "Hey", category: "education", body_markdown: "hey hey my my", tag_list: "ruby, rails, go", organization_id: organization.id } + } + end.to raise_error Pundit::NotAuthorizedError + end end describe "GETS /listings/edit" do @@ -88,24 +108,26 @@ RSpec.describe "/listings", type: :request do end it "updates bumped_at if action is bump" do - # block = create(:block, user_id: user.id, input_css: ".blue { color: blue;}") put "/listings/#{classified_listing.id}", params: { classified_listing: { action: "bump" } } expect(ClassifiedListing.last.bumped_at).to be > 10.seconds.ago end + it "updates publish if action is unpublish" do put "/listings/#{classified_listing.id}", params: { classified_listing: { action: "unpublish" } } expect(ClassifiedListing.last.published).to eq(false) end + it "updates body_markdown" do put "/listings/#{classified_listing.id}", params: { classified_listing: { body_markdown: "hello new markdown" } } expect(ClassifiedListing.last.body_markdown).to eq("hello new markdown") end + it "does not update body_markdown if not bumped/created recently" do classified_listing.update_column(:bumped_at, 50.hours.ago) put "/listings/#{classified_listing.id}", params: { @@ -113,6 +135,7 @@ RSpec.describe "/listings", type: :request do } expect(ClassifiedListing.last.body_markdown).not_to eq("hello new markdown") end + it "updates other fields" do put "/listings/#{classified_listing.id}", params: { classified_listing: { body_markdown: "hello new markdown", title: "New title!", tag_list: "new, tags, hey" } @@ -120,6 +143,7 @@ RSpec.describe "/listings", type: :request do expect(ClassifiedListing.last.title).to eq("New title!") expect(ClassifiedListing.last.cached_tag_list).to include("hey") end + it "does not update other fields" do classified_listing.update_column(:bumped_at, 50.hours.ago) put "/listings/#{classified_listing.id}", params: { diff --git a/spec/requests/notifications/reads_spec.rb b/spec/requests/notifications/reads_spec.rb index 0ec2fda68..b37db4dc5 100644 --- a/spec/requests/notifications/reads_spec.rb +++ b/spec/requests/notifications/reads_spec.rb @@ -17,9 +17,11 @@ RSpec.describe "Notifications::Reads", type: :request do expect(stubbed_service_object).to have_received(:mark_as_read).once end - it "marks personal and org Notifications as read" do - allow(user).to receive(:organization_id).and_return(1) - post "/notifications/reads/", params: { org_id: 1 } + it "marks personal and org notifications as read" do + org_admin = create(:user, :org_admin) + org_id = org_admin.organizations.first.id + sign_in org_admin + post "/notifications/reads/", params: { org_id: org_id } expect(response).to have_http_status(:ok) expect(stubbed_service_object).to have_received(:mark_as_read).twice end diff --git a/spec/requests/organizations_update_spec.rb b/spec/requests/organizations_update_spec.rb index 8ad26ba4d..9c20e4b9f 100644 --- a/spec/requests/organizations_update_spec.rb +++ b/spec/requests/organizations_update_spec.rb @@ -1,32 +1,33 @@ require "rails_helper" RSpec.describe "OrganizationsUpdate", type: :request do - let(:organization) { create(:organization) } - let(:user) { create(:user, organization_id: organization.id) } + let(:user) { create(:user, :org_admin) } + let(:org_id) { user.organizations.first.id } let(:article) { create(:article, user_id: user.id) } let(:comment) { create(:comment, user_id: user.id, commentable_id: article.id) } before do - user.org_admin = true sign_in user end it "updates org color with proper params" do - put "/organizations/#{organization.id}", params: { - organization: { text_color_hex: "#111111" } + put "/organizations/#{org_id}", params: { + organization: { id: org_id, text_color_hex: "#111111" } } expect(Organization.last.text_color_hex).to eq("#111111") end it "generates new secret" do secret = Organization.last.secret - post "/organizations/generate_new_secret" + post "/organizations/generate_new_secret", params: { + organization: { id: org_id } + } expect(Organization.last.secret).not_to eq(secret) end it "updates profile_updated_at" do - organization.update_column(:profile_updated_at, 2.weeks.ago) - put "/organizations/#{organization.id}", params: { organization: { text_color_hex: "#111111" } } - expect(organization.reload.profile_updated_at).to be > 2.minutes.ago + Organization.last.update_column(:profile_updated_at, 2.weeks.ago) + put "/organizations/#{org_id}", params: { organization: { id: org_id, text_color_hex: "#111111" } } + expect(Organization.last.profile_updated_at).to be > 2.minutes.ago end end diff --git a/spec/requests/user_organization_spec.rb b/spec/requests/user_organization_spec.rb index 1bd1fdf24..b99f59b37 100644 --- a/spec/requests/user_organization_spec.rb +++ b/spec/requests/user_organization_spec.rb @@ -7,17 +7,12 @@ RSpec.describe "UserOrganization", type: :request do context "when joining an org" do before { sign_in user } - it "joins org with proper secret" do - post "/users/join_org", params: { org_secret: organization.secret } - expect(user.organization_id).to eq(organization.id) - end - it "creates an organization_membership association" do post "/users/join_org", params: { org_secret: organization.secret } org_membership = OrganizationMembership.first expect(org_membership.persisted?).to eq true - expect(org_membership.user_id).to eq user.id - expect(org_membership.organization_id).to eq organization.id + expect(org_membership.user).to eq user + expect(org_membership.organization).to eq organization expect(org_membership.type_of_user).to eq "member" end @@ -28,7 +23,29 @@ RSpec.describe "UserOrganization", type: :request do it "correctly strips the secret of the org_secret param" do post "/users/join_org", params: { org_secret: organization.secret + " " } - expect(user.organization_id).to eq(organization.id) + expect(OrganizationMembership.exists?(user: user, organization: organization)).to eq true + end + end + + context "when creating a new org" do + before do + sign_in user + org_params = build(:organization).attributes + org_params["profile_image"] = Rack::Test::UploadedFile.new(Rails.root.join("app", "assets", "images", "android-icon-36x36.png"), "image/jpeg") + post "/organizations", params: { organization: org_params } + end + + it "creates the correct organization_membership association" do + org_membership = OrganizationMembership.first + expect(org_membership.persisted?).to eq true + expect(org_membership.user).to eq user + expect(org_membership.organization).to eq Organization.last + expect(org_membership.type_of_user).to eq "admin" + end + + it "redirects to the proper org settings page" do + expect(response.status).to eq 302 + expect(response.redirect_url).to include "/settings/organization/#{Organization.last.id}" end end @@ -37,71 +54,73 @@ RSpec.describe "UserOrganization", type: :request do before { sign_in org_member } - it "leaves org" do - post "/users/leave_org" - expect(org_member.organization_id).to eq(nil) - end - - it "deletes the org_membership association" do - create(:organization_membership, user_id: org_member.id, organization_id: org_member.organization_id) - post "/users/leave_org" - expect(OrganizationMembership.count).to eq 0 + it "leaves org and deletes the member's organization membership" do + org_id = org_member.organizations.first.id + post "/users/leave_org/#{org_id}" + expect(OrganizationMembership.exists?(user_id: org_member.id, organization_id: org_id)).to eq false end end context "when adding an org admin" do let(:org_admin) { create(:user, :org_admin) } - let(:user2) { create(:user, organization_id: org_admin.organization_id) } + let(:org_member) { create(:user, :org_member) } + let(:org_id) { org_admin.organizations.first.id } + let(:user2) { create(:user) } def add_org_admin org_admin + create(:organization_membership, user_id: user2.id, organization_id: org_id) sign_in org_admin - post "/users/add_org_admin", params: { user_id: user2.id } + post "/users/add_org_admin", params: { user_id: user2.id, organization_id: org_id } end it "adds org admin" do + org = org_admin.organizations.first add_org_admin - expect(User.last.org_admin).to eq(true) + expect(user2.org_admin?(org)).to eq(true) end it "creates the org_membership association" do add_org_admin - org_membership = OrganizationMembership.first + org_membership = OrganizationMembership.last expect(org_membership.persisted?).to eq true expect(org_membership.user_id).to eq user2.id - expect(org_membership.organization_id).to eq org_admin.organization_id + expect(org_membership.organization_id).to eq org_id expect(org_membership.type_of_user).to eq "admin" end - it "raises if user not org_admin" do - user.update(organization_id: organization.id) - expect { post "/users/add_org_admin", params: { user_id: user2.id } }. + it "raises not_authorized if user is not org_admin" do + org_member_org_id = org_member.organizations.first.id + sign_in org_member + expect { post "/users/add_org_admin", params: { user_id: user2.id, organization_id: org_member_org_id } }. to raise_error Pundit::NotAuthorizedError end end context "when removing an org admin" do let(:org_admin) { create(:user, :org_admin) } + let(:org_id) { org_admin.organizations.first.id } + let(:second_org_admin) { create(:user, :org_admin) } - before { sign_in org_admin } + before do + second_org_admin.organization_memberships.update_all(organization_id: org_id) + sign_in org_admin + end it "removes org admin" do - user2 = create(:user, organization_id: org_admin.organization_id, org_admin: true) - post "/users/remove_org_admin", params: { user_id: user2.id } - expect(User.last.org_admin).to eq(false) + post "/users/remove_org_admin", params: { user_id: second_org_admin.id, organization_id: org_id } + expect(second_org_admin.org_admin?(org_id)).to eq false end it "updates the correct org_membership association to a member level" do - user2 = create(:user, organization_id: org_admin.organization_id, org_admin: true) - org_membership = create(:organization_membership, organization_id: user2.organization_id, user_id: user2.id, type_of_user: "admin") - post "/users/remove_org_admin", params: { user_id: user2.id } + org_membership = second_org_admin.organization_memberships.first + post "/users/remove_org_admin", params: { user_id: second_org_admin.id, organization_id: org_id } expect(org_membership.reload.type_of_user).to eq "member" end it "remove_org_admin raises if user not org_admin" do - user.update(organization_id: organization.id) - user2 = create(:user, organization_id: organization.id, org_admin: true) - expect { post "/users/remove_org_admin", params: { user_id: user2.id } }. + org_admin.organization_memberships.update_all(type_of_user: "member") + expect { post "/users/remove_org_admin", params: { user_id: second_org_admin.id, organization_id: org_id } }. to raise_error Pundit::NotAuthorizedError end end diff --git a/spec/requests/user_profile_spec.rb b/spec/requests/user_profile_spec.rb index 2a11440a7..cf72ef860 100644 --- a/spec/requests/user_profile_spec.rb +++ b/spec/requests/user_profile_spec.rb @@ -31,7 +31,7 @@ RSpec.describe "UserProfiles", type: :request do end it "renders organization users on sidebar" do - user.update(organization_id: organization.id) + create(:organization_membership, user_id: user.id, organization_id: organization.id) get organization.path expect(response.body).to include user.profile_image_url end diff --git a/spec/requests/user_settings_spec.rb b/spec/requests/user_settings_spec.rb index 77d403aa9..393bda9c3 100644 --- a/spec/requests/user_settings_spec.rb +++ b/spec/requests/user_settings_spec.rb @@ -15,7 +15,7 @@ RSpec.describe "UserSettings", type: :request do before { sign_in user } it "renders various settings tabs properly" do - %w[organization switch-organizations misc account].each do |tab| + %w[organization misc account].each do |tab| get "/settings/#{tab}" expect(response.body).to include("Settings for") end @@ -72,7 +72,7 @@ RSpec.describe "UserSettings", type: :request do it "displays a flash with a reminder for the user to expect an email" do send_request - expect(flash[:notice]).to include("The export will be emailed to you shortly.") + expect(flash[:settings_notice]).to include("The export will be emailed to you shortly.") end it "hides the checkbox" do @@ -200,7 +200,7 @@ RSpec.describe "UserSettings", type: :request do it "renders a successful response message" do delete "/users/remove_association", params: { provider: "twitter" } - expect(flash[:notice]).to eq "Your Twitter account was successfully removed." + expect(flash[:settings_notice]).to eq "Your Twitter account was successfully removed." end it "does not show the Remove OAuth section afterward" do diff --git a/spec/system/organization/user_updates_org_settings_spec.rb b/spec/system/organization/user_updates_org_settings_spec.rb index cd704ff94..e30fb6450 100644 --- a/spec/system/organization/user_updates_org_settings_spec.rb +++ b/spec/system/organization/user_updates_org_settings_spec.rb @@ -15,13 +15,34 @@ RSpec.describe "Organization setting page(/settings/organization)", type: :syste expect(page).to have_text("Your organization was successfully created and you are an admin.") end + it "promotes a member to an admin" do + create(:organization_membership, user_id: user.id, organization_id: organization.id, type_of_user: "admin") + user2 = create(:user, username: "newuser") + create(:organization_membership, user_id: user2.id, organization_id: organization.id) + visit "settings/organization" + click_button("make admin") + page.driver.browser.switch_to.alert.accept + expect(page).to have_text("#{user2.name} is now an admin.") + end + + it "revokes an admin's privileges" do + create(:organization_membership, user_id: user.id, organization_id: organization.id, type_of_user: "admin") + user2 = create(:user, username: "newuser") + create(:organization_membership, user_id: user2.id, organization_id: organization.id, type_of_user: "admin") + visit "settings/organization" + click_button("revoke admin status") + page.driver.browser.switch_to.alert.accept + expect(page).to have_text("#{user2.name} is no longer an admin.") + end + it "remove user from organization" do - user.update(organization_id: organization.id, org_admin: true) - user2 = create(:user, username: "newuser", organization_id: organization.id) + create(:organization_membership, user_id: user.id, organization_id: organization.id, type_of_user: "admin") + user2 = create(:user, username: "newuser") + create(:organization_membership, user_id: user2.id, organization_id: organization.id) visit "settings/organization" click_button("Remove from org") page.driver.browser.switch_to.alert.accept - expect(page).not_to have_text(user2.name) + expect(page).to have_text("#{user2.name} is no longer part of your organization.") end def fill_in_org_form diff --git a/spec/system/organization/user_views_an_organization_spec.rb b/spec/system/organization/user_views_an_organization_spec.rb index 3c708a88a..3da84512d 100644 --- a/spec/system/organization/user_views_an_organization_spec.rb +++ b/spec/system/organization/user_views_an_organization_spec.rb @@ -1,8 +1,8 @@ require "rails_helper" RSpec.describe "Organization index", type: :system do - let!(:organization) { create(:organization) } - let!(:org_user) { create(:user, organization: organization) } + let!(:org_user) { create(:user, :org_member) } + let(:organization) { org_user.organizations.first } before do create_list(:article, 2, organization: organization) diff --git a/spec/system/user/user_edits_integrations_spec.rb b/spec/system/user/user_edits_integrations_spec.rb index 06095667a..7de09e6d4 100644 --- a/spec/system/user/user_edits_integrations_spec.rb +++ b/spec/system/user/user_edits_integrations_spec.rb @@ -28,7 +28,7 @@ RSpec.describe "User edits their integrations", type: :system, js: true do fill_in "Twitch Username", with: "TestTwitchUser" click_button("SUBMIT") - expect(page).to have_content "Your profile was successfully updated." + expect(page).to have_content "Your Twitch username was successfully updated." visit "/settings/integrations" expect(find_field("Twitch Username").value).to eq "TestTwitchUser"