diff --git a/Gemfile b/Gemfile index 0c3912f..ec13f35 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,7 @@ gem "sentry-sidekiq" gem 'http_accept_language' gem 'listen' gem 'config' +gem 'rack-cors', require: 'rack/cors' # Views and CSS gem 'haml' @@ -56,6 +57,7 @@ gem 'local_time' gem 'erb-formatter' + # Testing gem 'capybara' diff --git a/Gemfile.lock b/Gemfile.lock index 93a1962..e64fe50 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -291,6 +291,8 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (3.1.8) + rack-cors (2.0.2) + rack (>= 2.0.0) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) @@ -505,6 +507,7 @@ DEPENDENCIES local_time mysql2 (~> 0.5) puma (>= 5.0) + rack-cors rails (~> 7.2.1) rails-controller-testing rake diff --git a/Guardfile.rb b/Guardfile.rb index 7450267..25aa1cf 100644 --- a/Guardfile.rb +++ b/Guardfile.rb @@ -3,7 +3,7 @@ # TODO: Check this, just copied from Engine -guard 'rspec', :version => 2 do +guard 'rspec', version: 2 do watch(%r{^spec/.+_spec\.rb$}) watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } watch('spec/spec_helper.rb') { "spec" } @@ -13,7 +13,9 @@ watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } - watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] } + watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| + [ "spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", +"spec/acceptance/#{m[1]}_spec.rb" ] } watch(%r{^spec/support/(.+)\.rb$}) { "spec" } watch('spec/spec_helper.rb') { "spec" } watch('config/routes.rb') { "spec/routing" } diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index d38ae81..b06fc42 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -3,4 +3,3 @@ //= link_tree ../../javascript .js //= link_tree ../../../vendor/javascript .js //= link_tree ../builds -//= link auth.css diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 0000000..289aafd Binary files /dev/null and b/app/assets/images/logo.png differ diff --git a/app/assets/stylesheets/auth.css b/app/assets/stylesheets/auth.css deleted file mode 100644 index 16ee872..0000000 --- a/app/assets/stylesheets/auth.css +++ /dev/null @@ -1,4 +0,0 @@ -/* - *= require 'application.tailwind.css' - *= require_self - */ diff --git a/app/components/admin/user_row/component.rb b/app/components/admin/user_row/component.rb index 7d832a9..cff081f 100644 --- a/app/components/admin/user_row/component.rb +++ b/app/components/admin/user_row/component.rb @@ -1,5 +1,7 @@ module Admin::UserRow class Component < ApplicationComponent + include ButtonHelper + option :user option :confirmed, default: proc { true } option :confirm_path diff --git a/app/components/css_classes.rb b/app/components/css_classes.rb index 121bb74..ad0f7e1 100644 --- a/app/components/css_classes.rb +++ b/app/components/css_classes.rb @@ -4,6 +4,6 @@ module CssClasses private def merge_attributes(attributes) - attributes.merge(class: [attributes[:class], *self.class::DEFAULT_CLASSES].compact.join(' ')) + attributes.merge(class: [ attributes[:class], *self.class::DEFAULT_CLASSES ].compact.join(" ")) end end diff --git a/app/components/hovercard/component.rb b/app/components/hovercard/component.rb index 70c5817..690d0f9 100644 --- a/app/components/hovercard/component.rb +++ b/app/components/hovercard/component.rb @@ -4,6 +4,6 @@ module Hovercard class Component < ApplicationComponent option :path option :text, default: proc { "" } - option :placement_class, default: proc { 'right-2' } + option :placement_class, default: proc { "right-2" } end end diff --git a/app/components/identity/sidebar_item_component.rb b/app/components/identity/sidebar_item_component.rb index 398da6d..1dd5c5b 100644 --- a/app/components/identity/sidebar_item_component.rb +++ b/app/components/identity/sidebar_item_component.rb @@ -5,7 +5,7 @@ class Identity::SidebarItemComponent < ApplicationComponent option :title option :explanation option :active, default: proc { false } - option :icon, default: proc { 'identification' } + option :icon, default: proc { "identification" } def css_classes if @active diff --git a/app/components/identity/token_component.rb b/app/components/identity/token_component.rb index 287bd73..5d70f55 100644 --- a/app/components/identity/token_component.rb +++ b/app/components/identity/token_component.rb @@ -4,8 +4,8 @@ class Identity::TokenComponent < ApplicationComponent include ButtonHelper include Turbo::FramesHelper - # TODO: Move this to a proper token generator class. - TOKEN_PREFIX = Rails.env.staging? ? 'etm_beta_' : 'etm_' + # TODO: this should not be here + TOKEN_PREFIX = Rails.env.staging? ? "etm_beta_" : "etm_" def initialize(token:) @token = token diff --git a/app/components/login/action_arrow_component.rb b/app/components/login/action_arrow_component.rb index 68a358d..ec4527f 100644 --- a/app/components/login/action_arrow_component.rb +++ b/app/components/login/action_arrow_component.rb @@ -3,7 +3,7 @@ module Login class ActionArrowComponent < ApplicationComponent def call - heroicon @icon, options: { class: 'flex-shrink-0 ml-1 mt-px group-hover:translate-x-1 group-active:translate-x-1 transition duration-300', aria_hidden: true } + heroicon(@icon, options: { class: "flex-shrink-0 ml-1 mt-px group-hover:translate-x-1 group-active:translate-x-1 transition duration-300", aria_hidden: true }) end end end diff --git a/app/components/login/action_button_component.rb b/app/components/login/action_button_component.rb index 6664a5c..c67036d 100644 --- a/app/components/login/action_button_component.rb +++ b/app/components/login/action_button_component.rb @@ -4,16 +4,16 @@ module Login class ActionButtonComponent < ApplicationComponent include ButtonHelper - BASE_CLASSES = 'text-base flex items-center justify-center group' + BASE_CLASSES = "text-base flex items-center justify-center group" def initialize(form:, color: :default, size: :base, **attributes) @form = form - additional_classes = [BASE_CLASSES, attributes.delete(:class)].compact.join(' ') + additional_classes = [ BASE_CLASSES, attributes.delete(:class) ].compact.join(" ") @attributes = attributes.merge( class: button_classes(additional_classes, color:, size:), - type: attributes[:type] || 'submit' + type: attributes[:type] || "submit" ) end end diff --git a/app/components/login/button_component.rb b/app/components/login/button_component.rb index a385226..b6e88cf 100644 --- a/app/components/login/button_component.rb +++ b/app/components/login/button_component.rb @@ -3,7 +3,7 @@ module Login class ButtonComponent < ActionButtonComponent def initialize(form:) - super(form:, type: :submit, color: :primary, size: :lg, class: 'w-full !py-3 mt-5 bg-midnight-600') + super(form:, type: :submit, color: :primary, size: :lg, class: "w-full !py-3 mt-5 bg-midnight-600") end end end diff --git a/app/components/modal_component.rb b/app/components/modal_component.rb index 676bef4..0870c81 100644 --- a/app/components/modal_component.rb +++ b/app/components/modal_component.rb @@ -22,11 +22,11 @@ def stimulus @stimulus ||= if turbo_modal? StimulusConfig.new( - controller: 'modal', + controller: "modal", turbo_frame_id: :modal, - button_close_action: 'click->modal#close', - backdrop_close_action: 'mousedown->modal#closeWithBackdrop', - keyboard_close_action: 'keyup@window->modal#closeWithKeyboard' + button_close_action: "click->modal#close", + backdrop_close_action: "mousedown->modal#closeWithBackdrop", + keyboard_close_action: "keyup@window->modal#closeWithKeyboard" ) else StimulusConfig.new(turbo_frame_id: :static_modal) @@ -44,6 +44,6 @@ def close_link(inline_content, url = nil, **kwargs) private def turbo_modal? - request.headers['Turbo-Frame'] == 'modal' + request.headers["Turbo-Frame"] == "modal" end end diff --git a/app/components/notice_banner/component.rb b/app/components/notice_banner/component.rb index ec45f25..91dfe11 100644 --- a/app/components/notice_banner/component.rb +++ b/app/components/notice_banner/component.rb @@ -3,6 +3,6 @@ class Component < ApplicationComponent option :text option :path, default: proc { "" } option :button_text, default: proc { "" } - option :icon, default: proc { 'information-circle' } + option :icon, default: proc { "information-circle" } end end diff --git a/app/components/saved_scenario_user/user_row/component.rb b/app/components/saved_scenario_user/user_row/component.rb index 43f0e77..354d42e 100644 --- a/app/components/saved_scenario_user/user_row/component.rb +++ b/app/components/saved_scenario_user/user_row/component.rb @@ -28,9 +28,9 @@ def destroy_classes def destroy_text if @destroyable - t('saved_scenario_users.confirm_destroy.button') + t("saved_scenario_users.confirm_destroy.button") else - t('saved_scenario_users.confirm_destroy.not_possible') + t("saved_scenario_users.confirm_destroy.not_possible") end end end diff --git a/app/components/toast_component.rb b/app/components/toast_component.rb index bc11b03..ab5599b 100644 --- a/app/components/toast_component.rb +++ b/app/components/toast_component.rb @@ -5,8 +5,8 @@ def initialize(message:, type: :notice) @type = type if message.is_a?(Hash) - @title = no_break_on_hyphen(message[:title] || message['title']) - @message = no_break_on_hyphen(message[:message] || message['message']) + @title = no_break_on_hyphen(message[:title] || message["title"]) + @message = no_break_on_hyphen(message[:message] || message["message"]) else @message = no_break_on_hyphen(message) end @@ -16,6 +16,6 @@ def initialize(message:, type: :notice) # Replaces any hyphen in the message with a character taht won't trigger line breaks. def no_break_on_hyphen(string) - string.tr('-', '‑') + string.tr("-", "‑") end end diff --git a/app/controllers/admin/saved_scenarios_controller.rb b/app/controllers/admin/saved_scenarios_controller.rb index 2647f8f..610c18b 100644 --- a/app/controllers/admin/saved_scenarios_controller.rb +++ b/app/controllers/admin/saved_scenarios_controller.rb @@ -5,7 +5,7 @@ class SavedScenariosController < ApplicationController def index @saved_scenarios = SavedScenario.available .includes(:featured_scenario, :users) - .order('updated_at DESC') + .order("updated_at DESC") end end end diff --git a/app/controllers/admin/staff_applications_controller.rb b/app/controllers/admin/staff_applications_controller.rb index 18ebe4f..61b6831 100644 --- a/app/controllers/admin/staff_applications_controller.rb +++ b/app/controllers/admin/staff_applications_controller.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -require 'myetm/staff_applications' + +require "myetm/staff_applications" module Admin # Updates a staff application with a new URI. @@ -14,17 +15,23 @@ def index def update result = CreateStaffApplication.call( current_user, - MyEtm::StaffApplications.find(params[:format]), - uri: params[:uri].presence + MyEtm::StaffApplications.find(staff_application_params[:format]), + uri: staff_application_params[:uri].presence ) if result.success? - flash[:notice] = 'The application was updated.' + flash[:notice] = "The application was updated." else flash[:alert] = result.failure.errors.full_messages.to_sentence end redirect_to admin_applications_path end + + private + + def staff_application_params + params.permit(:format, :uri) + end end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 47118a6..061fda4 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -11,26 +11,27 @@ def org # All users def all - @users = User.all.includes(:saved_scenarios)#, :collections) + @users = User.all.includes(:saved_scenarios) # , :collections) end - # Instant confirmation for our users that struggel with their spam + # Instant confirmation for our users that struggle with their spam def confirm @user.confirm! + flash[:notice] = "User confirmed." end def edit; end def update - if @user.update!(user_params.compact_blank) - flash[:notice] = t('admin.users.edit.success') + if @user.update(user_params.compact_blank) + flash[:notice] = t("admin.users.edit.success") respond_to do |format| format.html { redirect_to(admin_users_path) } format.turbo_stream do render turbo_stream: [ - turbo_stream.update(:modal, ''), + turbo_stream.update(:modal, ""), turbo_user, turbo_notice ] @@ -48,19 +49,17 @@ def set_user end def user_params - params.require(:user).permit(:name, :email, :password, :admin) + attributes = [:name, :email, :password] + attributes << :admin if current_user&.admin? + params.require(:user).permit(*attributes) end def turbo_notice(message = nil) - if message.nil? - message = flash[:notice] - flash.delete(:notice) - end - + message ||= flash.delete(:notice) return if message.nil? turbo_stream.update( - 'toast', + "toast", ToastComponent.new(type: :notice, message:).render_in(view_context) ) end diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb new file mode 100644 index 0000000..010d0e6 --- /dev/null +++ b/app/controllers/api/v1/base_controller.rb @@ -0,0 +1,138 @@ +module Api + module V1 + class BaseController < ActionController::API + include ActionController::MimeResponds + + before_action :authenticate_request! + + rescue_from ActionController::ParameterMissing do |e| + render json: { errors: [ e.message ] }, status: :bad_request + end + + rescue_from ActiveRecord::RecordNotFound do |e| + render json: { + errors: [ "No such #{e.model.underscore.humanize.downcase}: #{e.id}" ] + }, status: :not_found + end + + rescue_from ActiveModel::RangeError do + render_not_found + end + + rescue_from CanCan::AccessDenied do |e| + if e.subject.is_a?(SavedScenario) && !e.subject.private? + render status: :forbidden, json: { errors: [ "Scenario does not belong to you" ] } + else + render_not_found + end + end + + rescue_from MyEtm::Auth::DecodeError do + render json: { errors: [ "Invalid or expired token" ] }, status: :unauthorized + end + + private + + + def decoded_token + return @decoded_token if defined?(@decoded_token) + + auth_header = request.headers["Authorization"] + token = auth_header&.split(" ")&.last + return nil unless token + + @decoded_token = MyEtm::Auth.decode(token) + rescue MyEtm::Auth::DecodeError, MyEtm::Auth::TokenExchangeError => e + Rails.logger.debug("Token decoding failed: #{e.message}") + nil + end + + # Fetch the user based on the decoded token or session. + def current_user + return @current_user if defined?(@current_user) + + if decoded_token + @current_user = find_user_from_token + elsif doorkeeper_token + @current_user = find_user_from_session + end + end + + def current_ability + @current_ability ||= begin + if current_user + if decoded_token + TokenAbility.new(decoded_token, current_user) + elsif doorkeeper_token + TokenAbility.new(doorkeeper_token, current_user) + end + else + GuestAbility.new + end + end + end + + def authenticate_request! + if doorkeeper_token + doorkeeper_authorize! + @current_user = User.find(doorkeeper_token.resource_owner_id) + elsif decoded_token + unless current_user + render json: { errors: [ "Unauthorized" ] }, status: :unauthorized + end + else + render json: { errors: [ "Authentication required" ] }, status: :unauthorized + end + end + + # Send a 404 response with an optional JSON body. + def render_not_found(body = { errors: [ "Not found" ] }) + render json: body, status: :not_found + end + + # Processes the controller action. + # + # Wraps around the default to rescue malformed params (e.g. JSON bodies) + # which is currently not possible with `rescue_from`. + # + # See: https://github.com/rails/rails/issues/38285 + def process_action(*args) + super + rescue ActionDispatch::Http::Parameters::ParseError => e + render status: 400, json: { errors: [ e.message ] } + end + + def track_token_use + if response.status == 200 && decoded_token && decoded_token.application_id.nil? + TrackPersonalAccessTokenUse.perform_later(decoded_token.id, Time.now.utc) + end + end + + def require_user + return if current_user + + redirect_to new_user_session_path + false + end + + private + + def find_user_from_token + return unless decoded_token + user_data = decoded_token[:user] + User.find_or_create_by(id: decoded_token[:sub]) do |user| + user.assign_attributes(user_data) + end + end + + def find_user_from_session + user = User.find_or_create_by(id: doorkeeper_token.resource_owner_id) if doorkeeper_token + user + rescue ActiveRecord::RecordNotFound + reset_session + redirect_to root_path + nil + end + end + end +end diff --git a/app/controllers/api/v1/saved_scenarios_controller.rb b/app/controllers/api/v1/saved_scenarios_controller.rb new file mode 100644 index 0000000..7a3b6ac --- /dev/null +++ b/app/controllers/api/v1/saved_scenarios_controller.rb @@ -0,0 +1,67 @@ +module Api + module V1 + class SavedScenariosController < BaseController + load_and_authorize_resource(class: SavedScenario, only: %i[index show create update destroy]) + + # GET /saved_scenarios or /saved_scenarios.json + def index + saved_scenarios = current_user + .saved_scenarios + .available + .includes(:featured_scenario, :users) + .order("updated_at DESC") + + render json: saved_scenarios + end + + def show + render json: current_user.saved_scenarios.find(params.require(:id)) + end + + # POST /saved_scenarios or /saved_scenarios.json + def create + @saved_scenario = SavedScenario.new(saved_scenario_params) + if @saved_scenario.save + # Associate the saved scenario with the current user + SavedScenarioUser.create!( + saved_scenario: @saved_scenario, + user: current_user, + role_id: User::Roles.index_of(:scenario_owner) + ) + + render json: @saved_scenario, status: :created + else + render json: { errors: @saved_scenario.errors.full_messages }, status: :unprocessable_entity + end + end + + # PATCH/PUT /saved_scenarios/1 or /saved_scenarios/1.json + def update + if @saved_scenario.update_with_api_params(saved_scenario_params) + render json: @saved_scenario, status: :ok + else + render json: @saved_scenario.errors, status: :unprocessable_entity + end + end + + # DELETE /saved_scenarios/1 or /saved_scenarios/1.json + def destroy + if @saved_scenario.destroy + render json: { message: "Scenario deleted successfully" }, status: :ok + else + render json: { error: "Failed to delete scenario" }, status: :unprocessable_entity + end + end + + private + + # Only allow a list of trusted parameters through. + def saved_scenario_params + params.require(:saved_scenario).permit( + :scenario_id, :title, + :description, :area_code, :end_year, :private, :discarded + ) + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bd23a95..6b308b2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,7 +4,6 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, # CSS nesting, and CSS :has. allow_browser versions: :modern - before_action :set_locale before_action :configure_sentry before_action :store_user_location!, if: :storable_location? @@ -85,12 +84,12 @@ def engine_client # # Returns true. def render_not_found(thing = nil) - content = Rails.root.join('public/404.html').read + content = Rails.root.join("public/404.html").read unless thing.nil? # Swap out the word "page" for something else, when appropriate. document = Nokogiri::HTML.parse(content) - header = document.at_css('h1') + header = document.at_css("h1") header.content = header.content.sub(/\bpage\b/, thing) content = document.to_s @@ -114,7 +113,7 @@ def turbo_notice(message = nil) return if message.nil? turbo_stream.update( - 'toast', + "toast", ToastComponent.new(type: :notice, message:).render_in(view_context) ) end @@ -128,7 +127,7 @@ def turbo_alert(message = nil) return if message.nil? turbo_stream.update( - 'toast', + "toast", ToastComponent.new(type: :alert, message:).render_in(view_context) ) end diff --git a/app/controllers/discarded_controller.rb b/app/controllers/discarded_controller.rb index fd73f3d..a3cb5b8 100644 --- a/app/controllers/discarded_controller.rb +++ b/app/controllers/discarded_controller.rb @@ -7,6 +7,6 @@ def index .saved_scenarios .discarded .includes(:featured_scenario, :users) - .order('updated_at DESC') + .order("updated_at DESC") end end diff --git a/app/controllers/featured_scenarios_controller.rb b/app/controllers/featured_scenarios_controller.rb index 0a00dad..a497a02 100644 --- a/app/controllers/featured_scenarios_controller.rb +++ b/app/controllers/featured_scenarios_controller.rb @@ -32,9 +32,8 @@ def update end end - # TODO: use turbo for a pop-up def confirm_destroy - render :confirm_destroy, layout: 'application' + render :confirm_destroy, layout: "application" end def destroy diff --git a/app/controllers/identity/access_tokens_controller.rb b/app/controllers/identity/access_tokens_controller.rb new file mode 100644 index 0000000..f2c5627 --- /dev/null +++ b/app/controllers/identity/access_tokens_controller.rb @@ -0,0 +1,30 @@ +module Identity + class AccessTokensController < ApplicationController + skip_before_action :verify_authenticity_token # Skip CSRF checking for cross-site requests + + def create + @client = Doorkeeper::Application.find_by(uid: params[:client_id]) + @user = User.find_by(id: params[:user_id]) + + # Validate client credentials + if valid_client? + token = generate_access_token + render json: { access_token: token, token_type: "Bearer", expires_in: 3600 } + else + render json: { error: "invalid_client" }, status: :unauthorized + end + end + + private + + def valid_client? + return false unless @client + true + end + + def generate_access_token + scopes = @client.scopes + MyEtm::Auth.user_jwt(@user, scopes: scopes, client_id: @client.uid) + end + end +end diff --git a/app/controllers/identity/newsletter_controller.rb b/app/controllers/identity/newsletter_controller.rb index a4c7a56..cefd8e9 100644 --- a/app/controllers/identity/newsletter_controller.rb +++ b/app/controllers/identity/newsletter_controller.rb @@ -7,7 +7,7 @@ class NewsletterController < ApplicationController before_action :require_mailchimp_configured def edit - return redirect_to(identity_profile_path) unless turbo_frame_request? + redirect_to(identity_profile_path) unless turbo_frame_request? end def update diff --git a/app/controllers/identity/settings_controller.rb b/app/controllers/identity/settings_controller.rb index 9af5fed..438b72c 100644 --- a/app/controllers/identity/settings_controller.rb +++ b/app/controllers/identity/settings_controller.rb @@ -17,7 +17,7 @@ def update_name if @user.update(update_params) redirect_to( identity_profile_path, - notice: I18n.t('identity.settings.update_name.success') + notice: I18n.t("identity.settings.update_name.success") ) SyncUserJob.perform_later(@user.id) @@ -41,7 +41,7 @@ def update_email redirect_to( identity_profile_path, - notice: I18n.t('identity.settings.update_email.success') + notice: I18n.t("identity.settings.update_email.success") ) else render(:edit_email, status: :unprocessable_entity) @@ -63,7 +63,7 @@ def update_password redirect_to( identity_profile_path, - notice: I18n.t('identity.settings.update_password.success') + notice: I18n.t("identity.settings.update_password.success") ) else render(:edit_password, status: :unprocessable_entity) diff --git a/app/controllers/identity/token_exchange_controller.rb b/app/controllers/identity/token_exchange_controller.rb deleted file mode 100644 index ee0da6d..0000000 --- a/app/controllers/identity/token_exchange_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -# app/controllers/token_exchange_controller.rb -module Identity - class TokenExchangeController < ApplicationController - before_action :validate_bearer_token - - def create - user = User.find_by(id: @user_id_from_bearer_token) - if user - jwt_token = MyEtm::Auth.user_jwt(user, scopes: extract_scopes_from_request, client_uri: client_uri) - render json: { jwt: jwt_token }, status: :ok - else - render json: { error: 'Invalid user' }, status: :unauthorized - end - end - - private - - # Decode and validate the Bearer token - def validate_bearer_token - bearer_token = request.headers['Authorization']&.split(' ')&.last - return render json: { error: 'Bearer token missing' }, status: :unauthorized unless bearer_token - - begin - decoded_token = decode_bearer_token(bearer_token) - @user_id_from_bearer_token = decoded_token[:sub] # Assuming `sub` holds the user ID - rescue JWT::DecodeError => e - render json: { error: 'Invalid bearer token' }, status: :unauthorized - end - end - - # Decode the Bearer token issued by the IdP - def decode_bearer_token(bearer_token) - key = MyEtm::Auth.signing_key - decoded_token, _header = JWT.decode(bearer_token, key, true, { algorithm: 'RS256' }) - decoded_token.symbolize_keys - end - - def extract_scopes_from_request - params[:scopes] || [] - end - - def client_uri - request.headers['Client-Uri'] - end - end -end diff --git a/app/controllers/identity/tokens_controller.rb b/app/controllers/identity/tokens_controller.rb index 399d95d..2d0d8af 100644 --- a/app/controllers/identity/tokens_controller.rb +++ b/app/controllers/identity/tokens_controller.rb @@ -29,7 +29,7 @@ def create else Identity::TokenMailer.created_token(result.value!).deliver_later - flash[:notice] = t('identity.tokens.created') + flash[:notice] = t("identity.tokens.created") redirect_to identity_tokens_path end end @@ -40,7 +40,7 @@ def create def destroy token.oauth_access_token.update!(revoked_at: Time.now.utc) - flash[:notice] = t('identity.tokens.revoked') + flash[:notice] = t("identity.tokens.revoked") respond_to do |format| format.html { redirect_to identity_tokens_path } @@ -49,10 +49,10 @@ def destroy ui_action = if current_user.personal_access_tokens.not_expired.count.positive? turbo_stream.remove(token) else - turbo_stream.replace(@token, partial: 'identity/tokens/empty_state') + turbo_stream.replace(@token, partial: "identity/tokens/empty_state") end - render turbo_stream: [ui_action, turbo_notice] + render turbo_stream: [ ui_action, turbo_notice ] end end end diff --git a/app/controllers/saved_scenario_users_controller.rb b/app/controllers/saved_scenario_users_controller.rb index 2958f59..0627e92 100644 --- a/app/controllers/saved_scenario_users_controller.rb +++ b/app/controllers/saved_scenario_users_controller.rb @@ -28,7 +28,7 @@ def new saved_scenario_id: @saved_scenario.id ) - render 'new', layout: 'application' + render "new", layout: "application" end # Creates a new SavedScenarioUser for the given SavedScenario. @@ -102,7 +102,7 @@ def update # # GET /saved_scenarios/:saved_scenario_id/users/:id/confirm_destroy def confirm_destroy - render 'confirm_destroy', layout: 'application' + render "confirm_destroy", layout: "application" end # Destroys an existing SavedScenarioUser for this SavedScenario. @@ -127,7 +127,8 @@ def destroy end else puts result.errors - flash[:alert] = "#{t('saved_scenario_users.errors.destroy')} #{t('saved_scenario_users.errors.general')}" + flash[:alert] = +"#{t('saved_scenario_users.errors.destroy')} #{t('saved_scenario_users.errors.general')}" respond_to do |format| format.turbo_stream do @@ -161,7 +162,7 @@ def assign_saved_scenario def assign_saved_scenario_user @saved_scenario_user = @saved_scenario.saved_scenario_users.find(permitted_params[:id]) rescue ActiveRecord::RecordNotFound - redirect_to saved_scenario_users_path, notice: 'Something went wrong' + redirect_to saved_scenario_users_path, notice: "Something went wrong" end def turbo_append_user diff --git a/app/controllers/saved_scenarios_controller.rb b/app/controllers/saved_scenarios_controller.rb index cf944a6..0d33f5d 100644 --- a/app/controllers/saved_scenarios_controller.rb +++ b/app/controllers/saved_scenarios_controller.rb @@ -18,7 +18,7 @@ def index .saved_scenarios .available .includes(:featured_scenario, :users) - .order('updated_at DESC') + .order("updated_at DESC") end # GET /saved_scenarios/1 or /saved_scenarios/1.json @@ -36,16 +36,28 @@ def edit # POST /saved_scenarios or /saved_scenarios.json def create - @saved_scenario = SavedScenario.new(saved_scenario_params) + ActiveRecord::Base.transaction do + @saved_scenario = SavedScenario.new(saved_scenario_params) - respond_to do |format| if @saved_scenario.save - format.html { - redirect_to @saved_scenario, notice: t("scenario.succesful_update") } - format.json { render :show, status: :created, location: @saved_scenario } + SavedScenarioUser.create!( + saved_scenario: @saved_scenario, + user: current_user, + role_id: User::Roles.index_of(:scenario_owner) + ) + + respond_to do |format| + format.html { + redirect_to @saved_scenario, notice: t("scenario.succesful_update") } + format.json { render :show, status: :created, location: @saved_scenario } + end else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @saved_scenario.errors, status: :unprocessable_entity } + respond_to do |format| + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @saved_scenario.errors, status: :unprocessable_entity } + end + # Rollback the transaction if the scenario save fails + raise ActiveRecord::Rollback end end end @@ -65,13 +77,13 @@ def update end def confirm_destroy - render :confirm_destroy, layout: 'application' + render :confirm_destroy, layout: "application" end # DELETE /saved_scenarios/1 or /saved_scenarios/1.json def destroy @saved_scenario.destroy - flash.notice = t('scenario.trash.deleted_flash') + flash.notice = t("scenario.trash.deleted_flash") redirect_to discarded_path end @@ -109,7 +121,7 @@ def discard @saved_scenario.discarded_at = Time.zone.now @saved_scenario.save(touch: false) - flash.notice = t('trash.discarded_flash') + flash.notice = t("trash.discarded_flash") flash[:undo_params] = undiscard_saved_scenario_path(@saved_scenario) end @@ -124,7 +136,7 @@ def undiscard @saved_scenario.discarded_at = nil @saved_scenario.save(touch: false) - flash.notice = t('trash.undiscarded_flash') + flash.notice = t("trash.undiscarded_flash") flash[:undo_params] = discard_saved_scenario_path(@saved_scenario) end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 0812f5f..876c353 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,5 +1,5 @@ class StaticPagesController < ApplicationController - before_action :require_feedback_email, only: [:send_message] + before_action :require_feedback_email, only: [ :send_message ] # invisible_captcha( # only: [:send_message], @@ -8,13 +8,12 @@ class StaticPagesController < ApplicationController # ) def empty - end def contact @message = ContactUsMessage.new( - name: current_user&.name, - email: current_user&.email, + name: current_user&.name || "", + email: current_user&.email || "", message: "" ) end @@ -32,13 +31,13 @@ def send_message ContactUsMailer.contact_email( @message, locale: I18n.locale, - user_agent: request.env['HTTP_USER_AGENT'] + user_agent: request.env["HTTP_USER_AGENT"] ).deliver - flash[:notice] = t('contact.contact.success_flash') + flash[:notice] = t("contact.contact.success_flash") redirect_to contact_url else - flash[:alert] = @message.errors.join(', ') + flash[:alert] = @message.errors.join(", ") redirect_to contact_url end end diff --git a/app/controllers/users/devise_controller.rb b/app/controllers/users/devise_controller.rb index 0fb241c..07036f8 100644 --- a/app/controllers/users/devise_controller.rb +++ b/app/controllers/users/devise_controller.rb @@ -2,7 +2,7 @@ module Users class DeviseController < ApplicationController - layout 'login' + layout "login" # Used to make Devise compatible with Turbo. class Responder < ActionController::Responder diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 40f9c56..f9c5601 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -2,14 +2,14 @@ module Users class RegistrationsController < Devise::RegistrationsController - before_action :configure_sign_up_params, only: [:create] - before_action :configure_account_update_params, only: [:update] - skip_before_action :require_no_authentication, only: [:new] - before_action :check_already_authenticated, only: [:new] + before_action :configure_sign_up_params, only: [ :create ] + before_action :configure_account_update_params, only: [ :update ] + skip_before_action :require_no_authentication, only: [ :new ] + before_action :check_already_authenticated, only: [ :new ] def confirm_destroy @counts = stats_for_destroy - render :confirm_destroy, layout: 'application' + render :confirm_destroy, layout: "application" end def destroy @@ -46,22 +46,21 @@ def update_resource(resource, params) # If you have extra params to permit, append them to the sanitizer. def configure_sign_up_params - devise_parameter_sanitizer.permit(:sign_up, keys: [:name]) + devise_parameter_sanitizer.permit(:sign_up, keys: [ :name ]) end # If you have extra params to permit, append them to the sanitizer. def configure_account_update_params - devise_parameter_sanitizer.permit(:account_update, keys: [:name]) + devise_parameter_sanitizer.permit(:account_update, keys: [ :name ]) end # Check if the user is already signed in and redirect back to client or to root. def check_already_authenticated - if user_signed_in? + return unless user_signed_in? token = MyEtm::Auth.user_jwt(current_user, client_uri: params[:redirect_to]) redirect_url = URI(params[:redirect_to] || root_path) redirect_url.query = URI.encode_www_form(token: token) redirect_to redirect_url.to_s - end end # Fetches information about what entities will be deleted with the account. diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index bde6c5f..77ccce8 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -4,8 +4,7 @@ module Users class SessionsController < Devise::SessionsController def create super do - - if session['user_return_to'].to_s.start_with?('/oauth/authorize') && is_flashing_format? + if session["user_return_to"].to_s.start_with?("/oauth/authorize") && is_flashing_format? # Don't show the flash message when redirecting to an OAuth action. flash.delete(:notice) end @@ -17,7 +16,6 @@ def destroy token = access_token super do - # TODO: Add logout_urls to the application and validate that the URL is permitted. if token token.revoke if token.accessible? diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index dd7eb8e..4301fd7 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,7 +17,7 @@ def update if @user.save @user.staff_applications.destroy_all unless @user.admin? - redirect_to users_path, notice: 'User updated' + redirect_to users_path, notice: "User updated" else render :edit end @@ -29,7 +29,7 @@ def create @user.role_id = params[:user][:role_id] if current_user && current_user.admin? if @user.save - redirect_to users_path, notice: 'User added' + redirect_to users_path, notice: "User added" else render :new end @@ -39,9 +39,9 @@ def resend_confirmation_email user = User.find_by(id: params[:id]) if user.nil? - flash[:notice] = 'User does not exist.' + flash[:notice] = "User does not exist." elsif user.confirmed_at? - flash[:notice] = 'User is already confirmed.' + flash[:notice] = "User is already confirmed." else user.send_confirmation_instructions flash[:notice] = "Confirmation email resent to #{user.email}." @@ -56,6 +56,8 @@ def find_user end def user_attributes - params.require(:user).permit(:email, :name, :admin) + attributes = [:email, :name] + attributes << :admin if current_user&.admin? + params.require(:user).permit(*attributes) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 78d9676..50c0df9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,7 +1,7 @@ module ApplicationHelper def notice_message if notice.is_a?(Hash) - notice[:message] || notice['message'] + notice[:message] || notice["message"] else notice end @@ -9,14 +9,14 @@ def notice_message def alert_message if alert.is_a?(Hash) - alert[:message] || alert['message'] + alert[:message] || alert["message"] else alert end end def identity_back_to_etm_url - session[:back_to_etm_url] || Settings.etmodel_uri || 'https://energytransitionmodel.com' + session[:back_to_etm_url] || Settings.etmodel_uri || "https://energytransitionmodel.com" end # Like simple_format, except without inserting breaks on newlines. @@ -27,12 +27,14 @@ def format_paragraphs(text) end def format_staff_config(config, app) + etengine_url = Settings.etengine.uri || "http://YOUR_ETENGINE_URL" format(config, app.attributes.symbolize_keys.merge( - myetm_url: root_url.chomp('/root'), - etengine_url: Settings.etengine.uri || 'http://YOUR_ETENGINE_URL', - etmodel_url: Settings.etmodel.uri || 'http://YOUR_ETMODEL_URL', - collections_url: Settings.collections.uri || 'http://YOUR_COLLECTIONS_URL' + myetm_url: root_url.chomp("/root"), + etengine_url: etengine_url, + etmodel_url: Settings.etmodel.uri || "http://YOUR_ETMODEL_URL", + collections_url: Settings.collections.uri || "http://YOUR_COLLECTIONS_URL", + etengine_uid: Doorkeeper::Application.find_by(uri: etengine_url)&.id || "YOUR_ETEngine_ID_HERE" )) end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 6fad8a4..3eaf9ae 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -15,21 +15,21 @@ module ButtonHelper }.freeze SIZES = { - sm: ['px-2 py-1'], - base: ['px-3 py-1.5'], - lg: ['px-4 py-2'] + sm: [ "px-2 py-1" ], + base: [ "px-3 py-1.5" ], + lg: [ "px-4 py-2" ] }.freeze NEGATE_PADDING = { - sm: { x: '-mx-2', y: '-my-1' }, - base: { x: '-mx-3', y: '-my-1.5' }, - lg: { x: '-mx-4', y: '-my-2' } + sm: { x: "-mx-2", y: "-my-1" }, + base: { x: "-mx-3", y: "-my-1.5" }, + lg: { x: "-mx-4", y: "-my-2" } }.freeze def button_classes(additional = nil, color: :default, size: :base, negate_padding: false) negative_margin = button_negated_padding_classes(size, negate_padding) - classes = (BASE_CLASSES + COLORS.fetch(color) + SIZES.fetch(size)).join(' ') + classes = (BASE_CLASSES + COLORS.fetch(color) + SIZES.fetch(size)).join(" ") classes += " #{negative_margin}" if negative_margin classes += " #{additional}" if additional diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb new file mode 100644 index 0000000..23e7dd0 --- /dev/null +++ b/app/helpers/email_helper.rb @@ -0,0 +1,6 @@ +module EmailHelper + def email_inline_image_tag(image, **options) + attachments.inline[image] = File.read(Rails.root.join("app/assets/images/#{image}")) + image_tag(attachments[image].url, **options) + end +end diff --git a/app/helpers/heroicon_helper.rb b/app/helpers/heroicon_helper.rb index c4f9665..d224271 100644 --- a/app/helpers/heroicon_helper.rb +++ b/app/helpers/heroicon_helper.rb @@ -2,4 +2,4 @@ module HeroiconHelper include Heroicon::Engine.helpers -end \ No newline at end of file +end diff --git a/app/helpers/identity/token_mailer_helper.rb b/app/helpers/identity/token_mailer_helper.rb index aa344da..1fd2260 100644 --- a/app/helpers/identity/token_mailer_helper.rb +++ b/app/helpers/identity/token_mailer_helper.rb @@ -4,11 +4,11 @@ module Identity module TokenMailerHelper def token_permissions(token) permissions = [ - ['View your public scenarios', 'public'], - ["View other people's public scenarios", 'public'], - ['View your private scenarios', 'scenarios:read'], - ['Create new scenarios and change your public and private scenarios', 'scenarios:write'], - ['Delete your public and private scenarios', 'scenarios:delete'] + [ "View your public scenarios", "public" ], + [ "View other people's public scenarios", "public" ], + [ "View your private scenarios", "scenarios:read" ], + [ "Create new scenarios and change your public and private scenarios", "scenarios:write" ], + [ "Delete your public and private scenarios", "scenarios:delete" ] ] permissions diff --git a/app/helpers/identity/tokens_helper.rb b/app/helpers/identity/tokens_helper.rb index 935fc72..d60a601 100644 --- a/app/helpers/identity/tokens_helper.rb +++ b/app/helpers/identity/tokens_helper.rb @@ -5,15 +5,15 @@ module TokensHelper def token_expiration_options(value) options_for_select( [ - token_expiration_option('n_days', 7), - token_expiration_option('n_days', 30), - token_expiration_option('n_days', 60), - token_expiration_option('n_days', 90), - token_expiration_option('one_year', 365), + token_expiration_option("n_days", 7), + token_expiration_option("n_days", 30), + token_expiration_option("n_days", 60), + token_expiration_option("n_days", 90), + token_expiration_option("one_year", 365), [ - t('identity.tokens.expiration_options.never'), - 'never', - { 'data-message' => t('identity.tokens.expiration_options.never_message') } + t("identity.tokens.expiration_options.never"), + "never", + { "data-message" => t("identity.tokens.expiration_options.never_message") } ] ], value @@ -22,11 +22,11 @@ def token_expiration_options(value) def token_expiration_option(message_key, days) [ - t(message_key, scope: 'identity.tokens.expiration_options', n: days), + t(message_key, scope: "identity.tokens.expiration_options", n: days), days, { - 'data-message' => t( - 'identity.tokens.expiration_options.expires_at_message', + "data-message" => t( + "identity.tokens.expiration_options.expires_at_message", date: l(days.days.from_now, format: :date) ) } diff --git a/app/jobs/identity/destroy_user_job.rb b/app/jobs/identity/destroy_user_job.rb index 3ea39bf..517ac1b 100644 --- a/app/jobs/identity/destroy_user_job.rb +++ b/app/jobs/identity/destroy_user_job.rb @@ -6,8 +6,7 @@ class Identity::DestroyUserJob < ApplicationJob def perform(user_id) user = User.find(user_id) - - MyEtm::Auth.client_app_client(user).delete('/api/v1/user') if Settings.etmodel_uri + MyEtm::Auth.model_client(user).delete("/api/v1/user") if Settings.etmodel.uri # Personal access tokens must be deleted before the access tokens, otherwise the destory will # fail due to a foreign key constraint. diff --git a/app/jobs/identity/sync_user_job.rb b/app/jobs/identity/sync_user_job.rb index d8334b6..1c43862 100644 --- a/app/jobs/identity/sync_user_job.rb +++ b/app/jobs/identity/sync_user_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require 'uri' -require 'net/http' +require "uri" +require "net/http" # Syncs a user's identity with ETModel. class Identity::SyncUserJob < ApplicationJob @@ -12,8 +12,8 @@ def perform(user_id) user = User.find(user_id) - MyEtm::Auth.client_app_client(user).put( - '/api/v1/user', + MyEtm::Auth.model_client(user).put( + "/api/v1/user", user.to_json(except: %i[admin created_at updated_at]) ) diff --git a/app/mailers/contact_us_mailer.rb b/app/mailers/contact_us_mailer.rb index 863596c..836f84a 100644 --- a/app/mailers/contact_us_mailer.rb +++ b/app/mailers/contact_us_mailer.rb @@ -11,7 +11,7 @@ def contact_email(message, locale: nil, user_agent: nil) to: Settings.feedback_email, from: Settings.feedback_email, reply_to: message.email, - subject: 'ETM Feedback' + subject: "ETM Feedback" ) end end diff --git a/app/mailers/scenario_invitation_mailer.rb b/app/mailers/scenario_invitation_mailer.rb new file mode 100644 index 0000000..e8a31ee --- /dev/null +++ b/app/mailers/scenario_invitation_mailer.rb @@ -0,0 +1,23 @@ +class ScenarioInvitationMailer < ApplicationMailer + helper(EmailHelper) + + def invite_user(email, inviter_name, new_role, saved_scenario_details) + @inviter_name = inviter_name + @saved_scenario_link = saved_scenario_link(saved_scenario_details[:id]) + @saved_scenario_title = saved_scenario_details[:title] + @new_role = new_role + + mail( + to: email, + from: Settings.mailer.from, + subject: "#{t('scenario_invitation_mailer.invite_user.subject')} #{@saved_scenario_title}", + template_name: "scenario_invitation" + ) + end + + private + + def saved_scenario_link(saved_scenario_id) + "#{Settings.etmodel_uri}/saved_scenarios/#{saved_scenario_id}" + end +end diff --git a/app/models/api/guest_ability.rb b/app/models/api/guest_ability.rb index 8ea0f19..d6f99e5 100644 --- a/app/models/api/guest_ability.rb +++ b/app/models/api/guest_ability.rb @@ -6,13 +6,9 @@ class GuestAbility include CanCan::Ability def initialize - can :create, Scenario - can :read, Scenario, private: false - can :update, Scenario, private: false - cannot :update, Scenario, private: false, id: ScenarioUser.pluck(:scenario_id) - - # Actions that involve reading one scenario and writing to another. - can :clone, Scenario, private: false + can :create, SavedScenario + can :read, SavedScenario, private: false + can :update, SavedScenario, private: false end end end diff --git a/app/models/api/token_ability.rb b/app/models/api/token_ability.rb index bfb980f..3412861 100644 --- a/app/models/api/token_ability.rb +++ b/app/models/api/token_ability.rb @@ -1,44 +1,63 @@ # frozen_string_literal: true module Api - # Describes the abilities of someone accessing the API with an access a token. + # Describes the abilities of someone accessing the API with an access token or session. class TokenAbility include CanCan::Ability def initialize(token, user) - can :read, Scenario, private: false + scopes = extract_scopes(token) + + can :read, SavedScenario, private: false # scenarios:read # -------------- - return unless token.scopes.include?('scenarios:read') + return unless scopes.include?("scenarios:read") - can :read, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_viewer)..).pluck(:scenario_id) + can :read, SavedScenario, + id: SavedScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_viewer)..).pluck(:saved_scenario_id) # scenarios:write # --------------- - return unless token.scopes.include?('scenarios:write') + return unless scopes.include?("scenarios:write") - can :create, Scenario + can :create, SavedScenario # Unowned public scenario. - can :update, Scenario, private: false - cannot :update, Scenario, private: false, id: ScenarioUser.pluck(:scenario_id) + can :update, SavedScenario, private: false + cannot(:update, SavedScenario, private: false, + id: SavedScenarioUser.pluck(:saved_scenario_id)) # Self-owned scenario. - can :update, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_collaborator)..).pluck(:scenario_id) + can :update, SavedScenario, + id: SavedScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_collaborator)..).pluck(:saved_scenario_id) # Actions that involve reading one scenario and writing to another. - can :clone, Scenario, private: false - can :clone, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_collaborator)..).pluck(:scenario_id) + can :clone, SavedScenario, private: false + can :clone, SavedScenario, + id: SavedScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_collaborator)..).pluck(:saved_scenario_id) # scenarios:delete # ---------------- - return unless token.scopes.include?('scenarios:delete') + return unless scopes.include?("scenarios:delete") + + can :destroy, SavedScenario, + id: SavedScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_owner)).pluck(:saved_scenario_id) + end + + private - can :destroy, Scenario, id: ScenarioUser.where(user_id: user.id, role_id: User::Roles.index_of(:scenario_owner)).pluck(:scenario_id) + def extract_scopes(token) + if token.respond_to?(:scopes) # doorkeeper_token + token.scopes + elsif token.is_a?(Hash) + token[:scopes] || token["scopes"] || [] # decoded_token + else + [] + end end end end diff --git a/app/models/contact_us_message.rb b/app/models/contact_us_message.rb index 63dcfc9..cb112d3 100644 --- a/app/models/contact_us_message.rb +++ b/app/models/contact_us_message.rb @@ -6,9 +6,9 @@ class ContactUsMessage < Dry::Struct include ActiveModel::AttributeMethods include ActiveModel::Conversion - attribute :name, Dry::Types['strict.string'] - attribute :email, Dry::Types['strict.string'] - attribute :message, Dry::Types['strict.string'] + attribute :name, Dry::Types["strict.string"] + attribute :email, Dry::Types["strict.string"] + attribute :message, Dry::Types["strict.string"] attr_reader :errors diff --git a/app/models/featured_scenario.rb b/app/models/featured_scenario.rb index 0d84974..0e9ed22 100644 --- a/app/models/featured_scenario.rb +++ b/app/models/featured_scenario.rb @@ -10,7 +10,7 @@ class FeaturedScenario < ApplicationRecord SORTABLE_GROUPS = [ *GROUPS, :rest, nil ].freeze belongs_to :saved_scenario - belongs_to :owner, class_name: 'FeaturedScenarioUser', optional: true + belongs_to :owner, class_name: "FeaturedScenarioUser", optional: true has_rich_text :description_en has_rich_text :description_nl diff --git a/app/models/nasty_cache.rb b/app/models/nasty_cache.rb index 18a98b3..192940c 100644 --- a/app/models/nasty_cache.rb +++ b/app/models/nasty_cache.rb @@ -93,7 +93,7 @@ def fetch(key, opts = {}) # alias to fetch(key, cache: true) def fetch_cached(key, &block) - fetch(key, :cache => true, &block) + fetch(key, cache: true, &block) end def get(key, opts = {}) @@ -113,9 +113,9 @@ def delete(key) Rails.cache.delete(rails_cache_key(key)) end -############## -# protected -############## + ############## + # protected + ############## # Expires Rails.cache. Easiest way is to Rails.cache.clear # Otherwise you could track the keys cached by MemoryCache in an @@ -163,7 +163,7 @@ def global_timestamp end def rails_cache_key(key) - ["NastyCache", local_timestamp, key].join('/') + [ "NastyCache", local_timestamp, key ].join("/") end # Internal: Sends a message to the Rails logger if NastyCache verbose mode diff --git a/app/models/oauth_application.rb b/app/models/oauth_application.rb index 1be3b34..e05964a 100644 --- a/app/models/oauth_application.rb +++ b/app/models/oauth_application.rb @@ -3,5 +3,7 @@ class OAuthApplication < ApplicationRecord include Doorkeeper::Orm::ActiveRecord::Mixins::Application + has_many :staff_applications, foreign_key: :application_id, dependent: :destroy + validates :uri, presence: true, 'doorkeeper/redirect_uri': true end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 3fda41a..4857cf0 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -3,10 +3,10 @@ class PersonalAccessToken < ApplicationRecord include Doorkeeper::Models::ExpirationTimeSqlMath - TOKEN_PREFIX = Rails.env.staging? ? 'etm_beta_' : 'etm_' + TOKEN_PREFIX = Rails.env.staging? ? "etm_beta_" : "etm_" belongs_to :user - belongs_to :oauth_access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy + belongs_to :oauth_access_token, class_name: "Doorkeeper::AccessToken", dependent: :destroy validates :name, presence: true diff --git a/app/models/saved_scenario.rb b/app/models/saved_scenario.rb index 7e58cb0..a2f29aa 100644 --- a/app/models/saved_scenario.rb +++ b/app/models/saved_scenario.rb @@ -35,12 +35,28 @@ def self.available kept end - def scenario(engine_client) - unless engine_client.is_a?(Faraday::Connection) - raise "SavedScenario#scenario expects an HTTP client as its first argument" - end + # def scenario(engine_client) + # unless engine_client.is_a?(Faraday::Connection) + # raise "SavedScenario#scenario expects an HTTP client as its first argument" + # end + + # @scenario ||= FetchAPIScenario.call(engine_client, scenario_id).or(nil) + # end + def restore_version(scenario_id) + return unless scenario_id && scenario_id_history.include?(scenario_id) + + discard_no = scenario_id_history.index(scenario_id) + discarded = scenario_id_history[discard_no + 1...] - @scenario ||= FetchAPIScenario.call(engine_client, scenario_id).or(nil) + self.scenario_id = scenario_id + self.scenario_id_history = scenario_id_history[...discard_no] + + discarded + end + + def scenario=(x) + @scenario = x + self.scenario_id = x.id unless x.nil? end # Public: Determines if this scenario can be loaded. @@ -75,4 +91,34 @@ def self.viewable_by?(user) 'saved_scenario_users.role_id': User::Roles.index_of(:scenario_viewer).. ) end + + def self.batch_load(saved_scenarios, options = {}) + saved_scenarios = saved_scenarios.to_a + ids = saved_scenarios.map(&:scenario_id) + loaded = Engine::Scenario.batch_load(ids, options).index_by(&:id) + + saved_scenarios.each do |saved| + saved.scenario = loaded[saved.scenario_id] + end + + saved_scenarios + end + + # Updates a saved scenario with parameters from the API controller. + def update_with_api_params(params) + incoming_id = params[:scenario_id] + update_scenario_id(incoming_id) + + self.attributes = params.except(:discarded, :scenario_id) + + if params.key?(:discarded) + if params[:discarded] + self.discarded_at ||= Time.current + else + self.discarded_at = nil + end + end + + save + end end diff --git a/app/models/staff_application.rb b/app/models/staff_application.rb index abcb904..f81970e 100644 --- a/app/models/staff_application.rb +++ b/app/models/staff_application.rb @@ -3,7 +3,7 @@ # Relates staff (admin) users to local OAuth applications. class StaffApplication < ApplicationRecord belongs_to :user - belongs_to :application, class_name: 'OAuthApplication', dependent: :destroy + belongs_to :application, class_name: "OAuthApplication", dependent: :destroy validates :name, presence: true validates :user, uniqueness: { scope: :name } diff --git a/app/models/user.rb b/app/models/user.rb index 5f9dd96..ba7873a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,25 +5,27 @@ class User < ApplicationRecord 3 => :scenario_owner }.freeze + attr_accessor :identity_user + # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable, :registerable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, - :confirmable, :trackable + :recoverable, :rememberable, :validatable, + :confirmable, :trackable # rubocop:disable Rails/InverseOf has_many :access_grants, - class_name: 'Doorkeeper::AccessGrant', + class_name: "Doorkeeper::AccessGrant", foreign_key: :resource_owner_id, dependent: :delete_all has_many :access_tokens, - class_name: 'Doorkeeper::AccessToken', + class_name: "Doorkeeper::AccessToken", foreign_key: :resource_owner_id, dependent: :delete_all has_many :oauth_applications, - class_name: 'OAuthApplication', + class_name: "OAuthApplication", dependent: :delete_all, as: :owner @@ -57,4 +59,28 @@ def featured? def as_json(options = {}) super(options.merge(except: Array(options[:except]))) end + + def self.from_identity!(identity_user) + where(id: identity_user.id).first_or_initialize.tap do |user| + is_new_user = !user.persisted? + user.identity_user = identity_user + user.name = identity_user.name + + user.save! + + # For new users, couple existing SavedScenarioUsers + if is_new_user + SavedScenarioUser + .where(user_email: user.email, user_id: nil) + .update_all(user_id: user.id, user_email: nil) + end + end + end + + # Finds or creates a user from a JWT token. + def self.from_jwt!(token) + id = token["sub"] + raise "Token does not contain user information" unless id.present? + User.find_or_create_by!(id: id) + end end diff --git a/app/services/api_scenario/update_privacy.rb b/app/services/api_scenario/update_privacy.rb index c082b96..481ca39 100644 --- a/app/services/api_scenario/update_privacy.rb +++ b/app/services/api_scenario/update_privacy.rb @@ -20,13 +20,13 @@ def call @http_client.put("/api/v3/scenarios/#{@id}", scenario: { private: @private }) ServiceResult.success rescue Faraday::ResourceNotFound - ServiceResult.failure('Scenario not found') + ServiceResult.failure("Scenario not found") rescue Faraday::UnprocessableEntityError # Trying to update an unowned scenario. Ignore. ServiceResult.success rescue Faraday::Error => e Sentry.capture_exception(e) - ServiceResult.failure('Failed to update scenario') + ServiceResult.failure("Failed to update scenario") end end end diff --git a/app/services/api_scenario/users/create.rb b/app/services/api_scenario/users/create.rb index 372ba24..88af5e6 100644 --- a/app/services/api_scenario/users/create.rb +++ b/app/services/api_scenario/users/create.rb @@ -17,11 +17,11 @@ def call ServiceResult.success( @http_client.post( "/api/v3/scenarios/#{@scenario_id}/users", - { scenario_users: [@scenario_user], invitation_args: @invitation_args } + { scenario_users: [ @scenario_user ], invitation_args: @invitation_args } ).body ) rescue Faraday::ResourceNotFound - ServiceResult.failure('Scenario not found') + ServiceResult.failure("Scenario not found") rescue Faraday::UnprocessableEntityError => e ServiceResult.single_failure_from_unprocessable_entity_on_multiple_objects(e) rescue Faraday::Error => e diff --git a/app/services/api_scenario/users/destroy.rb b/app/services/api_scenario/users/destroy.rb index c2e870e..de03c02 100644 --- a/app/services/api_scenario/users/destroy.rb +++ b/app/services/api_scenario/users/destroy.rb @@ -15,11 +15,11 @@ def initialize(http_client, scenario_id, scenario_user) def call ServiceResult.success( @http_client.delete( - "/api/v3/scenarios/#{@scenario_id}/users", scenario_users: [@scenario_user] + "/api/v3/scenarios/#{@scenario_id}/users", scenario_users: [ @scenario_user ] ).body ) rescue Faraday::ResourceNotFound - ServiceResult.failure('Scenario not found') + ServiceResult.failure("Scenario not found") rescue Faraday::UnprocessableEntityError => e ServiceResult.single_failure_from_unprocessable_entity_on_multiple_objects(e) rescue Faraday::Error => e diff --git a/app/services/api_scenario/users/update.rb b/app/services/api_scenario/users/update.rb index d3676a6..db4e0c3 100644 --- a/app/services/api_scenario/users/update.rb +++ b/app/services/api_scenario/users/update.rb @@ -15,13 +15,13 @@ def initialize(http_client, scenario_id, scenario_user) def call ServiceResult.success( @http_client.put( - "/api/v3/scenarios/#{@scenario_id}/users", scenario_users: [@scenario_user] + "/api/v3/scenarios/#{@scenario_id}/users", scenario_users: [ @scenario_user ] ).body ) rescue Faraday::ResourceNotFound - ServiceResult.failure('Scenario not found') + ServiceResult.failure("Scenario not found") rescue Faraday::ForbiddenError - ServiceResult.failure('No access to this scenario') + ServiceResult.failure("No access to this scenario") rescue Faraday::UnprocessableEntityError => e ServiceResult.single_failure_from_unprocessable_entity_on_multiple_objects(e) rescue Faraday::Error => e diff --git a/app/services/create_personal_access_token.rb b/app/services/create_personal_access_token.rb index 7675e92..9f83a79 100644 --- a/app/services/create_personal_access_token.rb +++ b/app/services/create_personal_access_token.rb @@ -7,7 +7,7 @@ class Params < Dry::Struct include ActiveModel::Validations extend ActiveModel::Translation - SCOPES_PUBLIC = 'openid public' + SCOPES_PUBLIC = "openid public" SCOPES_READ = "#{SCOPES_PUBLIC} scenarios:read".freeze SCOPES_WRITE = "#{SCOPES_READ} scenarios:write".freeze SCOPES_DELETE = "#{SCOPES_WRITE} scenarios:delete".freeze @@ -19,12 +19,12 @@ class Params < Dry::Struct delete: SCOPES_DELETE }.freeze - ExpiresType = Dry::Types['coercible.integer'] | Dry::Types['coercible.string'] + ExpiresType = Dry::Types["coercible.integer"] | Dry::Types["coercible.string"] - attribute :name, Dry::Types['coercible.string'].default('') - attribute :permissions, Dry::Types['coercible.symbol'].default(:public) - attribute :email_scope, Dry::Types['params.bool'].default(false) - attribute :profile_scope, Dry::Types['params.bool'].default(false) + attribute :name, Dry::Types["coercible.string"].default("") + attribute :permissions, Dry::Types["coercible.symbol"].default(:public) + attribute :email_scope, Dry::Types["params.bool"].default(false) + attribute :profile_scope, Dry::Types["params.bool"].default(false) attribute :expires_in, ExpiresType.default(30) transform_keys(&:to_sym) @@ -32,8 +32,8 @@ class Params < Dry::Struct validates :name, presence: true validates :permissions, inclusion: { in: SCOPES.keys } validates :expires_in, - numericality: { greater_than: 0, unless: :never_expires? }, - inclusion: { in: %w[never], message: :invalid, if: :never_expires? } + numericality: { greater_than: 0, unless: :never_expires? }, + inclusion: { in: %w[never], message: :invalid, if: :never_expires? } def to_oauth_token_params { @@ -49,13 +49,13 @@ def to_key = nil def scopes [ SCOPES[permissions], - email_scope ? 'email' : nil, - profile_scope ? 'profile' : nil - ].compact.join(' ') + email_scope ? "email" : nil, + profile_scope ? "profile" : nil + ].compact.join(" ") end def never_expires? - expires_in == 'never' + expires_in == "never" end end diff --git a/app/services/create_saved_scenario_user.rb b/app/services/create_saved_scenario_user.rb index 5da001f..748a39c 100644 --- a/app/services/create_saved_scenario_user.rb +++ b/app/services/create_saved_scenario_user.rb @@ -29,7 +29,7 @@ def call ServiceResult.success(saved_scenario_user) rescue ActiveRecord::RecordNotUnique - ServiceResult.failure('duplicate') + ServiceResult.failure("duplicate") end private diff --git a/app/services/create_staff_application.rb b/app/services/create_staff_application.rb index 0843518..c31fe3a 100644 --- a/app/services/create_staff_application.rb +++ b/app/services/create_staff_application.rb @@ -13,7 +13,7 @@ def self.call(user, app_config, uri: nil) uri = URI.parse(uri || app.uri || app_config.uri) - uri.path = '' + uri.path = "" uri.query = nil uri.fragment = nil diff --git a/app/services/service_result.rb b/app/services/service_result.rb index 5d5821d..b9a4784 100644 --- a/app/services/service_result.rb +++ b/app/services/service_result.rb @@ -27,10 +27,10 @@ def self.failure(errors = [], value = nil) # Public: Creates a failure result from a Faraday::UnprocessableEntityError. def self.failure_from_unprocessable_entity(exception, value = nil) - errors = exception.response[:body]['errors'] + errors = exception.response[:body]["errors"] if errors.is_a?(Hash) - errors = exception.response[:body]['errors'].flat_map do |key, messages| + errors = exception.response[:body]["errors"].flat_map do |key, messages| messages.map { |message| "#{key.humanize} #{message}" } end end @@ -39,7 +39,7 @@ def self.failure_from_unprocessable_entity(exception, value = nil) end def self.single_failure_from_unprocessable_entity_on_multiple_objects(exception, value = nil) - errors = exception.response[:body]['errors'] + errors = exception.response[:body]["errors"] errors = errors.values.first if errors.is_a?(Hash) failure(errors, value) @@ -74,7 +74,7 @@ def or(fallback = nil) # Public: Returns the value if the result is successful, otherwise raises an error with the # given message. def unwrap(error_msg = nil) - error_msg ||= 'Cannot unwrap failed ServiceResult' + error_msg ||= "Cannot unwrap failed ServiceResult" failure? ? raise("#{error_msg}: #{Array(@errors).join(', ')}") : @value end end diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index b51173e..f271432 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -3,7 +3,7 @@ %head %title= content_for(:title) || "ETM" %meta{ name: "viewport", content: "width=device-width,initial-scale=1"} - %meta{ name: "apple-mobile-web-app-capable", content: "yes"} + %meta{ name: "mobile-web-app-capable", content: "yes"} = csrf_meta_tags = csp_meta_tag @@ -34,6 +34,3 @@ = render partial: "layouts/block_right" = render partial: "layouts/footer" = turbo_frame_tag :modal - - - diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 878f192..6b1e942 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -3,7 +3,7 @@ %head %title= content_for(:title) || "ETM" %meta{ name: "viewport", content: "width=device-width,initial-scale=1"} - %meta{ name: "apple-mobile-web-app-capable", content: "yes"} + %meta{ name: "mobile-web-app-capable", content: "yes"} = csrf_meta_tags = csp_meta_tag diff --git a/app/views/layouts/login.html.erb b/app/views/layouts/login.html.erb index 1abbe48..39921bd 100644 --- a/app/views/layouts/login.html.erb +++ b/app/views/layouts/login.html.erb @@ -11,7 +11,6 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= favicon_link_tag asset_path("favicon.svg") %> - <%= stylesheet_link_tag "auth", "data-turbo-track": "reload" %> <%= javascript_importmap_tags "identity" %> diff --git a/app/views/saved_scenarios/show.html.haml b/app/views/saved_scenarios/show.html.haml index fac45b2..4632e35 100644 --- a/app/views/saved_scenarios/show.html.haml +++ b/app/views/saved_scenarios/show.html.haml @@ -1,7 +1,7 @@ - content_for :menu_title, @saved_scenario.localized_title(I18n.locale) = render(partial: "block_right_menu", locals: { saved_scenario: @saved_scenario }) -= render(SavedScenarios::Info::Component.new(path: saved_scenario_path(@saved_scenario), button_title: t('saved_scenario.open'), saved_scenario: @saved_scenario, time: time_ago_in_words(@saved_scenario.updated_at))) += render(SavedScenarios::Info::Component.new(path: "#{Settings.etmodel.uri}/saved_scenarios/#{@saved_scenario.id}/load?"+"engine_id=#{@saved_scenario.scenario_id}&name=#{URI.encode_www_form_component(@saved_scenario.title)}", button_title: t('saved_scenario.open'), saved_scenario: @saved_scenario, time: time_ago_in_words(@saved_scenario.updated_at))) - if flash[:undo_params] = render(NoticeBanner::Component.new(path: flash[:undo_params], text: notice_message, button_text: "#{t('undo')}?")) diff --git a/app/views/scenario_invitation_mailer/scenario_invitation.en.html.erb b/app/views/scenario_invitation_mailer/scenario_invitation.en.html.erb new file mode 100644 index 0000000..5a377a1 --- /dev/null +++ b/app/views/scenario_invitation_mailer/scenario_invitation.en.html.erb @@ -0,0 +1,45 @@ + + +
+Hello!
+ ++ <%= @inviter_name %> has just invited you to collaborate on the scenario "<%= @saved_scenario_title %>" of the Energy Transition Model as <%=t("scenario_invitation_mailer.roles.#{@new_role}")%>. +
++ If you already have an account with https://energytransitionmodel.com/, follow this link to view the scenario and start collaborating! +
+ ++ If you don't have an account yet, click here to register. +
+ ++ If you weren't expecting this invitation, please ignore this email. +
+ + <%=email_inline_image_tag("logo.png", style: "width: 200px; height: auto")%> + + + + diff --git a/app/views/scenario_invitation_mailer/scenario_invitation.nl.html.erb b/app/views/scenario_invitation_mailer/scenario_invitation.nl.html.erb new file mode 100644 index 0000000..e2e0bc3 --- /dev/null +++ b/app/views/scenario_invitation_mailer/scenario_invitation.nl.html.erb @@ -0,0 +1,45 @@ + + + +Hallo!
+ ++ <%= @inviter_name %> heeft je zojuist uitgenodigd om samen te werken aan het scenario "<%= @saved_scenario_title %>" van het Energietransitiemodel als <%=t("scenario_invitation_mailer.roles.#{@new_role}")%>. +
++ Als je al een account hebt bij https://energytransitionmodel.com/, volg dan deze link om het scenario te bekijken en begin met samenwerken! +
+ ++ Als je nog geen account hebt, klik dan hier om je aan te melden. +
+ ++ Als je deze uitnodiging niet verwachtte, negeer dan deze e-mail. +
+ + <%=email_inline_image_tag("logo.png", style: "width: 200px; height: auto")%> + + + + diff --git a/config/importmap.rb b/config/importmap.rb index a055d09..84f3d78 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -1,16 +1,17 @@ # Pin npm packages by running ./bin/importmap -pin 'identity', preload: true +pin "identity", preload: true pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" -pin 'focus-trap', to: 'https://ga.jspm.io/npm:focus-trap@7.0.0/dist/focus-trap.esm.js' -pin 'tabbable', to: 'https://ga.jspm.io/npm:tabbable@6.0.1/dist/index.esm.js' -pin 'stimulus-use', to: 'https://ga.jspm.io/npm:stimulus-use@0.51.1/dist/index.js' -pin 'focus-trap', to: 'https://ga.jspm.io/npm:focus-trap@7.0.0/dist/focus-trap.esm.js' -pin 'hotkeys-js', to: 'https://ga.jspm.io/npm:hotkeys-js@3.10.1/dist/hotkeys.esm.js' +pin "local-time", to: "https://cdn.skypack.dev/local-time" +pin "focus-trap", to: "https://ga.jspm.io/npm:focus-trap@7.0.0/dist/focus-trap.esm.js" +pin "tabbable", to: "https://ga.jspm.io/npm:tabbable@6.0.1/dist/index.esm.js" +pin "stimulus-use", to: "https://ga.jspm.io/npm:stimulus-use@0.51.1/dist/index.js" +pin "focus-trap", to: "https://ga.jspm.io/npm:focus-trap@7.0.0/dist/focus-trap.esm.js" +pin "hotkeys-js", to: "https://ga.jspm.io/npm:hotkeys-js@3.10.1/dist/hotkeys.esm.js" pin_all_from "app/javascript/controllers", under: "controllers" pin "trix" diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..31e0c9f --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,8 @@ +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins '*' + resource '/api/*', + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head] + end +end diff --git a/config/routes.rb b/config/routes.rb index 9eecd83..d724fdc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,9 +31,8 @@ get 'newsletter', to: 'newsletter#edit', as: :edit_newsletter post 'newsletter', to: 'newsletter#update' - post 'token_exchange', to: 'token_exchange#create' - resources :tokens, only: [:index, :new, :create, :destroy], as: :tokens + resources :access_tokens, only: [:create], as: :access_tokens resources :authorized_applications, only: [:index], as: :authorized_applications end @@ -61,6 +60,21 @@ end end + namespace :api do + namespace :v1 do + resources :saved_scenarios do + + member do + put :publish + put :unpublish + put :discard + put :undiscard + get :confirm_destroy + end + end + end + end + get :discarded, to: 'discarded#index' namespace :admin do diff --git a/config/settings.yml b/config/settings.yml index 750f344..aa115ea 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -41,6 +41,8 @@ myetm: iss: my-etm uri: http://localhost:3002 +identity: + token_exchange_url: http://localhost:3002/identity/token_exchange # set to true if the server won't have online access. this disables Sentry, # etc. diff --git a/db/migrate/20241018125758_create_saved_scenario_users.rb b/db/migrate/20241018125758_create_saved_scenario_users.rb index 9d3b11b..53d900d 100644 --- a/db/migrate/20241018125758_create_saved_scenario_users.rb +++ b/db/migrate/20241018125758_create_saved_scenario_users.rb @@ -9,7 +9,7 @@ def change add_index :saved_scenario_users, [:saved_scenario_id, :user_id, :role_id] - # Indeces to put unique constraints a scenario for a given user_id/email + # Indices to put unique constraints a scenario for a given user_id/email # to prevent duplicate records and roles add_index :saved_scenario_users, [:saved_scenario_id, :user_id], unique: true add_index :saved_scenario_users, [:saved_scenario_id, :user_email], unique: true diff --git a/lib/myetm/auth.rb b/lib/myetm/auth.rb index b85685a..318a8c2 100644 --- a/lib/myetm/auth.rb +++ b/lib/myetm/auth.rb @@ -3,24 +3,25 @@ module MyEtm # Contains useful methods for authentication. module Auth - module_function + DecodeError = Class.new(StandardError) + TokenExchangeError = Class.new(StandardError) # Generates a new signing key for use in development and saves it to the tmp directory. def signing_key_content - return ENV['OPENID_SIGNING_KEY'] if ENV['OPENID_SIGNING_KEY'].present? + return ENV["OPENID_SIGNING_KEY"] if ENV["OPENID_SIGNING_KEY"].present? - key_path = Rails.root.join('tmp/openid.key') + key_path = Rails.root.join("tmp/openid.key") return key_path.read if key_path.exist? - unless Rails.env.test? || Rails.env.development? || ENV['DOCKER_BUILD'] - raise 'No signing key is present. Please set the OPENID_SIGNING_KEY environment ' \ - 'variable or add the key to tmp/openid.key.' + unless Rails.env.test? || Rails.env.development? || ENV["DOCKER_BUILD"] + raise "No signing key is present. Please set the OPENID_SIGNING_KEY environment " \ + "variable or add the key to tmp/openid.key." end key = OpenSSL::PKey::RSA.new(2048).to_pem - unless ENV['DOCKER_BUILD'] + unless ENV["DOCKER_BUILD"] key_path.write(key) key_path.chmod(0o600) end @@ -34,37 +35,82 @@ def signing_key end # Creates a new JWT for the given user, authorizing requests to the provided client. - def user_jwt(user, scopes: [], client_uri: nil) - + def user_jwt(user = nil, scopes: [], client_id: nil) payload = { iss: Doorkeeper::OpenidConnect.configuration.issuer.call(user, nil), - aud: client_uri, + aud: client_id, exp: 1.minute.from_now.to_i, iat: Time.now.to_i, - scopes: scopes, # TODO: Grab scopes from the user based on their roles + scopes: scopes, sub: user.id, - user: user.as_json(only: %i[id name]) + user: user.as_json(only: %i[id admin]) } key = signing_key - JWT.encode(payload, key, 'RS256', typ: 'JWT', kid: key.to_jwk['kid']) + JWT.encode(payload, key, "RS256", typ: "JWT", kid: key.to_jwk["kid"]) end # Returns a Faraday client for a user, which will send requests to the specified client app. - def client_app_client(user, client_app, scopes: []) - client_uri = client_uri_for(client_app) - - Faraday.new(client_uri) do |conn| - conn.request(:authorization, 'Bearer', -> { user_jwt(user, scopes:) }) - conn.request(:json) - conn.response(:json) - conn.response(:raise_error) + def client_app_client(user, client_app) + client_app_client ||= begin + Faraday.new(client_app.uri) do |conn| + conn.request(:authorization, "Bearer", -> { + user_jwt(user, scopes: client_app.scopes, client_id: client_app.uid) }) + conn.request(:json) + conn.response(:json) + conn.response(:raise_error) + end end end - # Helper method to fetch the URI for the given client application (staff application). - def client_uri_for(client_app) - Settings.staff_applications[client_app].uri || raise("No URI configured for client: #{client_app}") + def engine_client(user) + engine = OAuthApplication.find_by(uri: Settings.etengine.uri) + client_app_client(user, engine) + end + + def model_client(user) + model = OAuthApplication.find_by(uri: Settings.etmodel.uri) + client_app_client(user, model) + end + + + # Checks if the token is in JWT format + def jwt_format?(token) + token.count(".") == 2 + end + + # Decodes a JWT token + def decode(jwt_token) + decoded_token = JWT.decode( + jwt_token, + signing_key.public_key, + true, + algorithm: "RS256" + ).first + verify_claims(decoded_token) + decoded_token.symbolize_keys + rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e + raise DecodeError, "Token verification failed: #{e.message}" end + + # Verifies specific claims within the token payload + def verify_claims(decoded_token) + # Verify the issuer + issuer = Doorkeeper::OpenidConnect.configuration.issuer.call(nil, nil) + raise DecodeError, "Invalid issuer" unless decoded_token["iss"] == issuer + + # Dynamically fetch the expected audiences from OAuth applications + expected_audiences = Doorkeeper::Application.pluck(:uid) + unless expected_audiences.include?(decoded_token["aud"]) + raise DecodeError, "Invalid audience" + end + + # Verify the token has not expired + raise DecodeError, + "Token has expired" unless decoded_token["exp"] && decoded_token["exp"] > Time.now.to_i + end + + module_function :decode, :jwt_format?, :verify_claims, :signing_key_content, :user_jwt, + :signing_key, :model_client, :engine_client, :client_app_client end end diff --git a/lib/myetm/mailchimp.rb b/lib/myetm/mailchimp.rb index 99a75b7..84b1847 100644 --- a/lib/myetm/mailchimp.rb +++ b/lib/myetm/mailchimp.rb @@ -16,7 +16,7 @@ def client end Faraday.new(Settings.mailchimp.list_url) do |conn| - conn.request(:authorization, :basic, '', Settings.mailchimp.api_key) + conn.request(:authorization, :basic, "", Settings.mailchimp.api_key) conn.request(:json) conn.response(:json) conn.response(:raise_error) @@ -35,7 +35,7 @@ def fetch_subscriber(email) # Returns if the e-mail address is subscribed to the newsletter. def subscribed?(email) - %w[pending subscribed].include?(fetch_subscriber(email)['status']) + %w[pending subscribed].include?(fetch_subscriber(email)["status"]) rescue Faraday::ResourceNotFound false end diff --git a/lib/myetm/staff_applications.rb b/lib/myetm/staff_applications.rb index 79927aa..4210ad8 100644 --- a/lib/myetm/staff_applications.rb +++ b/lib/myetm/staff_applications.rb @@ -1,97 +1,100 @@ -# frozen_string_literal: true -require_relative 'staff_applications/app_config' + # frozen_string_literal: true -module MyEtm - # Holds config information about OAuth accounts created for staff users. - module StaffApplications - class << self - def all - [etengine, etmodel, collections] - end + require_relative "staff_applications/app_config" - def find(key) - case key.to_sym - when :etengine then etengine - when :etmodel then etmodel - when :collections then collections - else raise ArgumentError, "unknown application: #{key}" + module MyEtm + # Holds config information about OAuth accounts created for staff users. + module StaffApplications + class << self + def all + [ etengine, etmodel, collections ] end - end - private + def find(key) + case key.to_sym + when :etengine then etengine + when :etmodel then etmodel + when :collections then collections + else raise ArgumentError, "unknown application: #{key}" + end + end - # TODO fix these app configs - include Engine! + private - def etengine - AppConfig.new( - key: 'etengine', - name: 'Engine (Local)', - scopes: 'openid email profile public scenarios:read scenarios:write scenarios:delete', - uri: 'http://localhost:3000', - redirect_path: '/auth/identity/callback', - run_command: 'bundle exec rails server -p %