+
+
diff --git a/config/application.rb b/config/application.rb
index 9c595bc..58dc54c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -1,18 +1,6 @@
require_relative "boot"
-require "rails"
-# Pick the frameworks you want:
-require "active_model/railtie"
-require "active_job/railtie"
-require "active_record/railtie"
-require "active_storage/engine"
-require "action_controller/railtie"
-require "action_mailer/railtie"
-require "action_mailbox/engine"
-require "action_text/engine"
-require "action_view/railtie"
-require "action_cable/engine"
-# require "rails/test_unit/railtie"
+require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
@@ -23,25 +11,52 @@ class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2
- # Please, add to the `ignore` list any other `lib` subdirectories that do
- # not contain `.rb` files, or that should not be reloaded or eager loaded.
- # Common ones are `templates`, `generators`, or `middleware`, for example.
- config.autoload_lib(ignore: %w[assets tasks])
-
# Configuration for the application, engines, and railties goes here.
- #
+ config.autoload_paths << Rails.root.join("lib")
+ config.autoload_paths << Rails.root.join("app/components")
+
+
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
+ config.i18n.enforce_available_locales = true
+ config.i18n.available_locales = %i[en nl]
+ config.i18n.default_locale = :en
+
+ config.active_support.deprecation = :log
+
+ config.encoding = "utf-8"
+
+ config.filter_parameters << :password
# Don't generate system test files.
config.generators.system_tests = nil
+ config.to_prepare do
+ Doorkeeper::AuthorizationsController.layout 'login'
+ Doorkeeper::AuthorizedApplicationsController.layout 'identity'
+ end
+
config.generators do |g|
g.template_engine :haml
g.test_framework :rspec, fixture: false
end
+
+ # Mail
+ if (email_conf = Rails.root.join('config/email.yml')).file?
+ email_env_conf = YAML.load_file(email_conf)[Rails.env]
+
+ if email_env_conf
+ config.action_mailer.smtp_settings = email_env_conf.symbolize_keys
+ else
+ raise "Missing e-mail settings for #{ Rails.env.inspect } environment"
+ end
+ end
+ # Allow commonly used classes in `serialize` columns (user and balanced values)
+ config.active_record.yaml_column_permitted_classes =
+ [Symbol, ActiveSupport::HashWithIndifferentAccess]
end
+ Date::DATE_FORMATS[:default] = "%d-%m-%Y"
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 25d4cbb..d30a299 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -71,7 +71,7 @@
# config.cache_store = :mem_cache_store
# Use a real queuing backend for Active Job (and separate queues per environment).
- # config.active_job.queue_adapter = :resque
+ config.active_job.queue_adapter = :sidekiq
# config.active_job.queue_name_prefix = "my_etm_production"
# Disable caching for Action Mailer templates even if Action Controller
diff --git a/config/importmap.rb b/config/importmap.rb
index 909dfc5..e581b33 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -1,4 +1,5 @@
# Pin npm packages by running ./bin/importmap
+pin 'identity', preload: true
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
new file mode 100644
index 0000000..d1b44ee
--- /dev/null
+++ b/config/initializers/devise.rb
@@ -0,0 +1,327 @@
+# frozen_string_literal: true
+
+# Handles Devise failures, ensuring compatibility with Turbo.
+class DeviseTurboFailureApp < Devise::FailureApp
+ def respond
+ if request_format == :turbo_stream
+ redirect
+ else
+ super
+ end
+ end
+
+ def skip_format?
+ %w[html turbo_stream */*].include?(request_format.to_s)
+ end
+end
+
+# Assuming you have not yet modified this file, each configuration option below
+# is set to its default value. Note that some are commented out while others
+# are not: uncommented lines are intended to protect your configuration from
+# breaking changes in upgrades (i.e., in the event that future versions of
+# Devise change the default values for those options).
+#
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+ # The secret key used by Devise. Devise uses this key to generate
+ # random tokens. Changing this key will render invalid all existing
+ # confirmation, reset password and unlock tokens in the database.
+ # Devise will use the `secret_key_base` as its `secret_key`
+ # by default. You can change it below and use your own secret key.
+ # config.secret_key = '...'
+
+ # ==> Controller configuration
+ # Configure the parent class to the devise controllers.
+ config.parent_controller = 'Users::DeviseController'
+
+ # ==> Mailer Configuration
+ # Configure the e-mail address which will be shown in Devise::Mailer,
+ # note that it will be overwritten if you use your own mailer class
+ # with default "from" parameter.
+ config.mailer_sender = 'no-reply@energytransitionmodel.com'
+
+ # Configure the class responsible to send e-mails.
+ # config.mailer = 'Devise::Mailer'
+
+ # Configure the parent class responsible to send e-mails.
+ # config.parent_mailer = 'ActionMailer::Base'
+
+ # ==> ORM configuration
+ # Load and configure the ORM. Supports :active_record (default) and
+ # :mongoid (bson_ext recommended) by default. Other ORMs may be
+ # available as additional gems.
+ require 'devise/orm/active_record'
+
+ # ==> Configuration for any authentication mechanism
+ # Configure which keys are used when authenticating a user. The default is
+ # just :email. You can configure it to use [:username, :subdomain], so for
+ # authenticating a user, both parameters are required. Remember that those
+ # parameters are used only when authenticating and not when retrieving from
+ # session. If you need permissions, you should implement that in a before filter.
+ # You can also supply a hash where the value is a boolean determining whether
+ # or not authentication should be aborted when the value is not present.
+ # config.authentication_keys = [:email]
+
+ # Configure parameters from the request object used for authentication. Each entry
+ # given should be a request method and it will automatically be passed to the
+ # find_for_authentication method and considered in your model lookup. For instance,
+ # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+ # The same considerations mentioned for authentication_keys also apply to request_keys.
+ # config.request_keys = []
+
+ # Configure which authentication keys should be case-insensitive.
+ # These keys will be downcased upon creating or modifying a user and when used
+ # to authenticate or find a user. Default is :email.
+ config.case_insensitive_keys = [:email]
+
+ # Configure which authentication keys should have whitespace stripped.
+ # These keys will have whitespace before and after removed upon creating or
+ # modifying a user and when used to authenticate or find a user. Default is :email.
+ config.strip_whitespace_keys = [:email]
+
+ # Tell if authentication through request.params is enabled. True by default.
+ # It can be set to an array that will enable params authentication only for the
+ # given strategies, for example, `config.params_authenticatable = [:database]` will
+ # enable it only for database (email + password) authentication.
+ # config.params_authenticatable = true
+
+ # Tell if authentication through HTTP Auth is enabled. False by default.
+ # It can be set to an array that will enable http authentication only for the
+ # given strategies, for example, `config.http_authenticatable = [:database]` will
+ # enable it only for database authentication.
+ # For API-only applications to support authentication "out-of-the-box", you will likely want to
+ # enable this with :database unless you are using a custom strategy.
+ # The supported strategies are:
+ # :database = Support basic authentication with authentication key + password
+ # config.http_authenticatable = false
+
+ # If 401 status code should be returned for AJAX requests. True by default.
+ # config.http_authenticatable_on_xhr = true
+
+ # The realm used in Http Basic Authentication. 'Application' by default.
+ # config.http_authentication_realm = 'Application'
+
+ # It will change confirmation, password recovery and other workflows
+ # to behave the same regardless if the e-mail provided was right or wrong.
+ # Does not affect registerable.
+ # config.paranoid = true
+
+ # By default Devise will store the user in session. You can skip storage for
+ # particular strategies by setting this option.
+ # Notice that if you are skipping storage for all authentication paths, you
+ # may want to disable generating routes to Devise's sessions controller by
+ # passing skip: :sessions to `devise_for` in your config/routes.rb
+ config.skip_session_storage = [:http_auth]
+
+ # By default, Devise cleans up the CSRF token on authentication to
+ # avoid CSRF token fixation attacks. This means that, when using AJAX
+ # requests for sign in and sign up, you need to get a new CSRF token
+ # from the server. You can disable this option at your own risk.
+ # config.clean_up_csrf_token_on_authentication = true
+
+ # When false, Devise will not attempt to reload routes on eager load.
+ # This can reduce the time taken to boot the app but if your application
+ # requires the Devise mappings to be loaded during boot time the application
+ # won't boot properly.
+ # config.reload_routes = true
+
+ # ==> Configuration for :database_authenticatable
+ # For bcrypt, this is the cost for hashing the password and defaults to 12. If
+ # using other algorithms, it sets how many times you want the password to be hashed.
+ # The number of stretches used for generating the hashed password are stored
+ # with the hashed password. This allows you to change the stretches without
+ # invalidating existing passwords.
+ #
+ # Limiting the stretches to just one in testing will increase the performance of
+ # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+ # a value less than 10 in other environments. Note that, for bcrypt (the default
+ # algorithm), the cost increases exponentially with the number of stretches (e.g.
+ # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation).
+ config.stretches = Rails.env.test? ? 1 : 12
+
+ # Set up a pepper to generate the hashed password.
+ # config.pepper = '3b0d59faff297c4137650887bc07b9199baaac376920ac9aaaeba61f438254f07bd30059f0918c6e62016efc0525a601bcb81f731c7b9d094212475fe620066c'
+
+ # Send a notification to the original email when the user's email is changed.
+ config.send_email_changed_notification = true
+
+ # Send a notification email when the user's password is changed.
+ config.send_password_change_notification = true
+
+ # ==> Configuration for :confirmable
+ # A period that the user is allowed to access the website even without
+ # confirming their account. For instance, if set to 2.days, the user will be
+ # able to access the website for two days without confirming their account,
+ # access will be blocked just in the third day.
+ # You can also set it to nil, which will allow the user to access the website
+ # without confirming their account.
+ # Default is 0.days, meaning the user cannot access the website without
+ # confirming their account.
+ config.allow_unconfirmed_access_for = 2.days
+
+ # A period that the user is allowed to confirm their account before their
+ # token becomes invalid. For example, if set to 3.days, the user can confirm
+ # their account within 3 days after the mail was sent, but on the fourth day
+ # their account can't be confirmed with the token any more.
+ # Default is nil, meaning there is no restriction on how long a user can take
+ # before confirming their account.
+ # config.confirm_within = 3.days
+
+ # If true, requires any email changes to be confirmed (exactly the same way as
+ # initial account confirmation) to be applied. Requires additional unconfirmed_email
+ # db field (see migrations). Until confirmed, new email is stored in
+ # unconfirmed_email column, and copied to email column on successful confirmation.
+ config.reconfirmable = true
+
+ # Defines which key will be used when confirming an account
+ # config.confirmation_keys = [:email]
+
+ # ==> Configuration for :rememberable
+ # The time the user will be remembered without asking for credentials again.
+ # config.remember_for = 2.weeks
+
+ # Invalidates all the remember me tokens when the user signs out.
+ config.expire_all_remember_me_on_sign_out = true
+
+ # If true, extends the user's remember period when remembered via cookie.
+ # config.extend_remember_period = false
+
+ # Options to be passed to the created cookie. For instance, you can set
+ # secure: true in order to force SSL only cookies.
+ # config.rememberable_options = {}
+
+ # ==> Configuration for :validatable
+ # Range for password length.
+ config.password_length = 8..128
+
+ # Email regex used to validate email formats. It simply asserts that
+ # one (and only one) @ exists in the given string. This is mainly
+ # to give user feedback and not to assert the e-mail validity.
+ config.email_regexp = /\A[^@\s]+@[^@\s]+\z/
+
+ # ==> Configuration for :timeoutable
+ # The time you want to timeout the user session without activity. After this
+ # time the user will be asked for credentials again. Default is 30 minutes.
+ # config.timeout_in = 30.minutes
+
+ # ==> Configuration for :lockable
+ # Defines which strategy will be used to lock an account.
+ # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+ # :none = No lock strategy. You should handle locking by yourself.
+ # config.lock_strategy = :failed_attempts
+
+ # Defines which key will be used when locking and unlocking an account
+ # config.unlock_keys = [:email]
+
+ # Defines which strategy will be used to unlock an account.
+ # :email = Sends an unlock link to the user email
+ # :time = Re-enables login after a certain amount of time (see :unlock_in below)
+ # :both = Enables both strategies
+ # :none = No unlock strategy. You should handle unlocking by yourself.
+ # config.unlock_strategy = :both
+
+ # Number of authentication tries before locking an account if lock_strategy
+ # is failed attempts.
+ # config.maximum_attempts = 20
+
+ # Time interval to unlock the account if :time is enabled as unlock_strategy.
+ # config.unlock_in = 1.hour
+
+ # Warn on the last attempt before the account is locked.
+ # config.last_attempt_warning = true
+
+ # ==> Configuration for :recoverable
+ #
+ # Defines which key will be used when recovering the password for an account
+ # config.reset_password_keys = [:email]
+
+ # Time interval you can reset your password with a reset password key.
+ # Don't put a too small interval or your users won't have the time to
+ # change their passwords.
+ config.reset_password_within = 6.hours
+
+ # When set to false, does not sign a user in automatically after their password is
+ # reset. Defaults to true, so a user is signed in automatically after a reset.
+ # config.sign_in_after_reset_password = true
+
+ # ==> Configuration for :encryptable
+ # Allow you to use another hashing or encryption algorithm besides bcrypt (default).
+ # You can use :sha1, :sha512 or algorithms from others authentication tools as
+ # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20
+ # for default behavior) and :restful_authentication_sha1 (then you should set
+ # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper).
+ #
+ # Require the `devise-encryptable` gem when using anything other than bcrypt
+ # config.encryptor = :sha512
+
+ # ==> Scopes configuration
+ # Turn scoped views on. Before rendering "sessions/new", it will first check for
+ # "users/sessions/new". It's turned off by default because it's slower if you
+ # are using only default views.
+ # config.scoped_views = false
+
+ # Configure the default scope given to Warden. By default it's the first
+ # devise role declared in your routes (usually :user).
+ # config.default_scope = :user
+
+ # Set this configuration to false if you want /users/sign_out to sign out
+ # only the current scope. By default, Devise signs out all scopes.
+ # config.sign_out_all_scopes = true
+
+ # ==> Navigation configuration
+ # Lists the formats that should be treated as navigational. Formats like
+ # :html, should redirect to the sign in page when the user does not have
+ # access, but formats like :xml or :json, should return 401.
+ #
+ # If you have any extra navigational formats, like :iphone or :mobile, you
+ # should add them to the navigational formats lists.
+ #
+ # The "*/*" below is required to match Internet Explorer requests.
+ # config.navigational_formats = ['*/*', :html]
+
+ # The default HTTP method used to sign out a resource. Default is :delete.
+ # config.sign_out_via = :delete
+
+ # ==> OmniAuth
+ # Add a new OmniAuth provider. Check the wiki for more information on setting
+ # up on your models and hooks.
+ # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo'
+
+ # ==> Warden configuration
+ # If you want to use other strategies, that are not supported by Devise, or
+ # change the failure app, you can configure them inside the config.warden block.
+ #
+ config.warden do |manager|
+ # manager.intercept_401 = false
+ # manager.default_strategies(scope: :user).unshift :some_external_strategy
+ manager.failure_app = DeviseTurboFailureApp
+ end
+
+ # ==> Mountable engine configurations
+ # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+ # is mountable, there are some extra configurations to be taken into account.
+ # The following options are available, assuming the engine is mounted as:
+ #
+ # mount MyEngine, at: '/my_engine'
+ #
+ # The router that invoked `devise_for`, in the example above, would be:
+ # config.router_name = :my_engine
+ #
+ # When using OmniAuth, Devise cannot automatically set OmniAuth path,
+ # so you need to do it manually. For the users scope, it would be:
+ # config.omniauth_path_prefix = '/my_engine/users/auth'
+
+ # ==> Turbolinks configuration
+ # If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly:
+ #
+ # ActiveSupport.on_load(:devise_failure_app) do
+ # include Turbolinks::Controller
+ # end
+
+ # ==> Configuration for :registerable
+
+ # When set to false, does not sign a user in automatically after their password is
+ # changed. Defaults to true, so a user is signed in automatically after changing a password.
+ # config.sign_in_after_change_password = true
+end
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
new file mode 100644
index 0000000..8e160c4
--- /dev/null
+++ b/config/initializers/doorkeeper.rb
@@ -0,0 +1,505 @@
+# frozen_string_literal: true
+
+Doorkeeper.configure do
+ # Change the ORM that doorkeeper will use (requires ORM extensions installed).
+ # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
+ orm :active_record
+
+ # Use base58 for tokens since it can be copied more easily than base64.
+ default_generator_method :base58
+
+ # This block will be called to check whether the resource owner is authenticated or not.
+ resource_owner_authenticator do
+ # Put your resource owner authentication logic here.
+ # doorkeeper-openid_connect requires this to return nil when not authenticated.
+ if current_user
+ current_user
+ else
+ session[:user_return_to] = request.fullpath
+ redirect_to(new_user_session_url)
+ nil
+ end
+ end
+
+ # If you didn't skip applications controller from Doorkeeper routes in your application routes.rb
+ # file then you need to declare this block in order to restrict access to the web interface for
+ # adding oauth authorized applications. In other case it will return 403 Forbidden response
+ # every time somebody will try to access the admin web interface.
+ #
+ admin_authenticator do
+ if current_user
+ head :forbidden unless current_user.admin?
+ else
+ redirect_to new_user_session_url
+ end
+ end
+
+ # You can use your own model classes if you need to extend (or even override) default
+ # Doorkeeper models such as `Application`, `AccessToken` and `AccessGrant.
+ #
+ # Be default Doorkeeper ActiveRecord ORM uses it's own classes:
+ #
+ # access_token_class "Doorkeeper::AccessToken"
+ # access_grant_class "Doorkeeper::AccessGrant"
+ application_class 'OAuthApplication'
+ #
+ # Don't forget to include Doorkeeper ORM mixins into your custom models:
+ #
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken - for access token
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessGrant - for access grant
+ # * ::Doorkeeper::Orm::ActiveRecord::Mixins::Application - for application (OAuth2 clients)
+ #
+ # For example:
+ #
+ # access_token_class "MyAccessToken"
+ #
+ # class MyAccessToken < ApplicationRecord
+ # include ::Doorkeeper::Orm::ActiveRecord::Mixins::AccessToken
+ #
+ # self.table_name = "hey_i_wanna_my_name"
+ #
+ # def destroy_me!
+ # destroy
+ # end
+ # end
+
+ # Enables polymorphic Resource Owner association for Access Tokens and Access Grants.
+ # By default this option is disabled.
+ #
+ # Make sure you properly setup you database and have all the required columns (run
+ # `bundle exec rails generate doorkeeper:enable_polymorphic_resource_owner` and execute Rails
+ # migrations).
+ #
+ # If this option enabled, Doorkeeper will store not only Resource Owner primary key
+ # value, but also it's type (class name). See "Polymorphic Associations" section of
+ # Rails guides: https://guides.rubyonrails.org/association_basics.html#polymorphic-associations
+ #
+ # [NOTE] If you apply this option on already existing project don't forget to manually
+ # update `resource_owner_type` column in the database and fix migration template as it will
+ # set NOT NULL constraint for Access Grants table.
+ #
+ # use_polymorphic_resource_owner
+
+ # If you are planning to use Doorkeeper in Rails 5 API-only application, then you might
+ # want to use API mode that will skip all the views management and change the way how
+ # Doorkeeper responds to a requests.
+ #
+ # api_only
+
+ # Enforce token request content type to application/x-www-form-urlencoded.
+ # It is not enabled by default to not break prior versions of the gem.
+ #
+ # enforce_content_type
+
+ # Authorization Code expiration time (default: 10 minutes).
+ #
+ # authorization_code_expires_in 10.minutes
+
+ # Access token expiration time (default: 2 hours).
+ # If you want to disable expiration, set this to `nil`.
+ #
+ # access_token_expires_in 2.hours
+
+ # Assign custom TTL for access tokens. Will be used instead of access_token_expires_in
+ # option if defined. In case the block returns `nil` value Doorkeeper fallbacks to
+ # +access_token_expires_in+ configuration option value. If you really need to issue a
+ # non-expiring access token (which is not recommended) then you need to return
+ # Float::INFINITY from this block.
+ #
+ # `context` has the following properties available:
+ #
+ # * `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+ # * `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+ # * `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+ # * `resource_owner` - authorized resource owner instance (if present)
+ #
+ # custom_access_token_expires_in do |context|
+ # context.client.additional_settings.implicit_oauth_expiration
+ # end
+
+ # Use a custom class for generating the access token.
+ # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-access-token-generator
+ #
+ # access_token_generator '::Doorkeeper::JWT'
+
+ # The controller +Doorkeeper::ApplicationController+ inherits from.
+ # Defaults to +ActionController::Base+ unless +api_only+ is set, which changes the default to
+ # +ActionController::API+. The return value of this option must be a stringified class name.
+ # See https://doorkeeper.gitbook.io/guides/configuration/other-configurations#custom-controllers
+ #
+ base_controller 'ApplicationController'
+
+ # Reuse access token for the same resource owner within an application (disabled by default).
+ #
+ # This option protects your application from creating new tokens before old **valid** one becomes
+ # expired so your database doesn't bloat. Keep in mind that when this option is enabled Doorkeeper
+ # doesn't update existing token expiration time, it will create a new token instead if no active matching
+ # token found for the application, resources owner and/or set of scopes.
+ # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
+ #
+ # You can not enable this option together with +hash_token_secrets+.
+ #
+ # reuse_access_token
+
+ # In case you enabled `reuse_access_token` option Doorkeeper will try to find matching
+ # token using `matching_token_for` Access Token API that searches for valid records
+ # in batches in order not to pollute the memory with all the database records. By default
+ # Doorkeeper uses batch size of 10 000 records. You can increase or decrease this value
+ # depending on your needs and server capabilities.
+ #
+ # token_lookup_batch_size 10_000
+
+ # Set a limit for token_reuse if using reuse_access_token option
+ #
+ # This option limits token_reusability to some extent.
+ # If not set then access_token will be reused unless it expires.
+ # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/1189
+ #
+ # This option should be a percentage(i.e. (0,100])
+ #
+ # token_reuse_limit 100
+
+ # Only allow one valid access token obtained via client credentials
+ # per client. If a new access token is obtained before the old one
+ # expired, the old one gets revoked (disabled by default)
+ #
+ # When enabling this option, make sure that you do not expect multiple processes
+ # using the same credentials at the same time (e.g. web servers spanning
+ # multiple machines and/or processes).
+ #
+ # revoke_previous_client_credentials_token
+
+ # Hash access and refresh tokens before persisting them.
+ # This will disable the possibility to use +reuse_access_token+
+ # since plain values can no longer be retrieved.
+ #
+ # Note: If you are already a user of doorkeeper and have existing tokens
+ # in your installation, they will be invalid without adding 'fallback: :plain'.
+ #
+ # hash_token_secrets
+ # By default, token secrets will be hashed using the
+ # +Doorkeeper::Hashing::SHA256+ strategy.
+ #
+ # If you wish to use another hashing implementation, you can override
+ # this strategy as follows:
+ #
+ # hash_token_secrets using: '::Doorkeeper::Hashing::MyCustomHashImpl'
+ #
+ # Keep in mind that changing the hashing function will invalidate all existing
+ # secrets, if there are any.
+
+ # Hash application secrets before persisting them.
+ #
+ # hash_application_secrets
+ #
+ # By default, applications will be hashed
+ # with the +Doorkeeper::SecretStoring::SHA256+ strategy.
+ #
+ # If you wish to use bcrypt for application secret hashing, uncomment
+ # this line instead:
+ #
+ # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt'
+
+ # When the above option is enabled, and a hashed token or secret is not found,
+ # you can allow to fall back to another strategy. For users upgrading
+ # doorkeeper and wishing to enable hashing, you will probably want to enable
+ # the fallback to plain tokens.
+ #
+ # This will ensure that old access tokens and secrets
+ # will remain valid even if the hashing above is enabled.
+ #
+ # This can be done by adding 'fallback: plain', e.g. :
+ #
+ # hash_application_secrets using: '::Doorkeeper::SecretStoring::BCrypt', fallback: :plain
+
+ # Issue access tokens with refresh token (disabled by default), you may also
+ # pass a block which accepts `context` to customize when to give a refresh
+ # token or not. Similar to +custom_access_token_expires_in+, `context` has
+ # the following properties:
+ #
+ # `client` - the OAuth client application (see Doorkeeper::OAuth::Client)
+ # `grant_type` - the grant type of the request (see Doorkeeper::OAuth)
+ # `scopes` - the requested scopes (see Doorkeeper::OAuth::Scopes)
+ #
+ use_refresh_token
+
+ # Provide support for an owner to be assigned to each registered application (disabled by default)
+ # Optional parameter confirmation: true (default: false) if you want to enforce ownership of
+ # a registered application
+ # NOTE: you must also run the rails g doorkeeper:application_owner generator
+ # to provide the necessary support
+ #
+ enable_application_owner confirmation: true
+
+ # Define access token scopes for your provider
+ # For more information go to
+ # https://doorkeeper.gitbook.io/guides/ruby-on-rails/scopes
+ #
+ default_scopes :public
+ optional_scopes :email,
+ :openid,
+ :profile,
+ :roles,
+ :'scenarios:read',
+ :'scenarios:write',
+ :'scenarios:delete'
+
+ # Allows to restrict only certain scopes for grant_type.
+ # By default, all the scopes will be available for all the grant types.
+ #
+ # Keys to this hash should be the name of grant_type and
+ # values should be the array of scopes for that grant type.
+ # Note: scopes should be from configured_scopes (i.e. default or optional)
+ #
+ # scopes_by_grant_type password: [:write], client_credentials: [:update]
+
+ # Forbids creating/updating applications with arbitrary scopes that are
+ # not in configuration, i.e. +default_scopes+ or +optional_scopes+.
+ # (disabled by default)
+ #
+ enforce_configured_scopes
+
+ # Change the way client credentials are retrieved from the request object.
+ # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+ # falls back to the `:client_id` and `:client_secret` params from the `params` object.
+ # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+ # for more information on customization
+ #
+ # client_credentials :from_basic, :from_params
+
+ # Change the way access token is authenticated from the request object.
+ # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
+ # falls back to the `:access_token` or `:bearer_token` params from the `params` object.
+ # Check out https://github.com/doorkeeper-gem/doorkeeper/wiki/Changing-how-clients-are-authenticated
+ # for more information on customization
+ #
+ # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param
+
+ # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled
+ # by default in non-development environments). OAuth2 delegates security in
+ # communication to the HTTPS protocol so it is wise to keep this enabled.
+ #
+ # Callable objects such as proc, lambda, block or any object that responds to
+ # #call can be used in order to allow conditional checks (to allow non-SSL
+ # redirects to localhost for example).
+ force_ssl_in_redirect_uri do |uri|
+ !(Rails.env.development? || uri.host != 'localhost' || uri.host.split('.').last != 'test')
+ end
+
+ # Specify what redirect URI's you want to block during Application creation.
+ # Any redirect URI is allowed by default.
+ #
+ # You can use this option in order to forbid URI's with 'javascript' scheme
+ # for example.
+ #
+ forbid_redirect_uri do |uri|
+ %w[data vbscript javascript].include?(uri.scheme.to_s.downcase)
+ end
+
+ # Allows to set blank redirect URIs for Applications in case Doorkeeper configured
+ # to use URI-less OAuth grant flows like Client Credentials or Resource Owner
+ # Password Credentials. The option is on by default and checks configured grant
+ # types, but you **need** to manually drop `NOT NULL` constraint from `redirect_uri`
+ # column for `oauth_applications` database table.
+ #
+ # You can completely disable this feature with:
+ #
+ # allow_blank_redirect_uri false
+ #
+ # Or you can define your custom check:
+ #
+ # allow_blank_redirect_uri do |grant_flows, client|
+ # client.superapp?
+ # end
+ allow_blank_redirect_uri true
+
+ # Specify how authorization errors should be handled.
+ # By default, doorkeeper renders json errors when access token
+ # is invalid, expired, revoked or has invalid scopes.
+ #
+ # If you want to render error response yourself (i.e. rescue exceptions),
+ # set +handle_auth_errors+ to `:raise` and rescue Doorkeeper::Errors::InvalidToken
+ # or following specific errors:
+ #
+ # Doorkeeper::Errors::TokenForbidden, Doorkeeper::Errors::TokenExpired,
+ # Doorkeeper::Errors::TokenRevoked, Doorkeeper::Errors::TokenUnknown
+ #
+ # handle_auth_errors :raise
+
+ # Customize token introspection response.
+ # Allows to add your own fields to default one that are required by the OAuth spec
+ # for the introspection response. It could be `sub`, `aud` and so on.
+ # This configuration option can be a proc, lambda or any Ruby object responds
+ # to `.call` method and result of it's invocation must be a Hash.
+ #
+ # custom_introspection_response do |token, context|
+ # {
+ # "sub": "Z5O3upPC88QrAjx00dis",
+ # "aud": "https://protected.example.net/resource",
+ # "username": User.find(token.resource_owner_id).username
+ # }
+ # end
+ #
+ # or
+ #
+ # custom_introspection_response CustomIntrospectionResponder
+
+ # Specify what grant flows are enabled in array of Strings. The valid
+ # strings and the flows they enable are:
+ #
+ # "authorization_code" => Authorization Code Grant Flow
+ # "implicit" => Implicit Grant Flow
+ # "password" => Resource Owner Password Credentials Grant Flow
+ # "client_credentials" => Client Credentials Grant Flow
+ #
+ # If not specified, Doorkeeper enables authorization_code and
+ # client_credentials.
+ #
+ # implicit and password grant flows have risks that you should understand
+ # before enabling:
+ # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.2
+ # https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.3
+ #
+ grant_flows %w[authorization_code client_credentials implicit_oidc]
+
+ # Allows to customize OAuth grant flows that +each+ application support.
+ # You can configure a custom block (or use a class respond to `#call`) that must
+ # return `true` in case Application instance supports requested OAuth grant flow
+ # during the authorization request to the server. This configuration +doesn't+
+ # set flows per application, it only allows to check if application supports
+ # specific grant flow.
+ #
+ # For example you can add an additional database column to `oauth_applications` table,
+ # say `t.array :grant_flows, default: []`, and store allowed grant flows that can
+ # be used with this application there. Then when authorization requested Doorkeeper
+ # will call this block to check if specific Application (passed with client_id and/or
+ # client_secret) is allowed to perform the request for the specific grant type
+ # (authorization, password, client_credentials, etc).
+ #
+ # Example of the block:
+ #
+ # ->(flow, client) { client.grant_flows.include?(flow) }
+ #
+ # In case this option invocation result is `false`, Doorkeeper server returns
+ # :unauthorized_client error and stops the request.
+ #
+ # @param allow_grant_flow_for_client [Proc] Block or any object respond to #call
+ # @return [Boolean] `true` if allow or `false` if forbid the request
+ #
+ # allow_grant_flow_for_client do |grant_flow, client|
+ # # `grant_flows` is an Array column with grant
+ # # flows that application supports
+ #
+ # client.grant_flows.include?(grant_flow)
+ # end
+
+ # If you need arbitrary Resource Owner-Client authorization you can enable this option
+ # and implement the check your need. Config option must respond to #call and return
+ # true in case resource owner authorized for the specific application or false in other
+ # cases.
+ #
+ # Be default all Resource Owners are authorized to any Client (application).
+ #
+ # authorize_resource_owner_for_client do |client, resource_owner|
+ # resource_owner.admin? || client.owners_allowlist.include?(resource_owner)
+ # end
+
+ # Hook into the strategies' request & response life-cycle in case your
+ # application needs advanced customization or logging:
+ #
+ # before_successful_strategy_response do |request|
+ # puts "BEFORE HOOK FIRED! #{request}"
+ # end
+ #
+ # after_successful_strategy_response do |request, response|
+ # puts "AFTER HOOK FIRED! #{request}, #{response}"
+ # end
+
+ # Hook into Authorization flow in order to implement Single Sign Out
+ # or add any other functionality. Inside the block you have an access
+ # to `controller` (authorizations controller instance) and `context`
+ # (Doorkeeper::OAuth::Hooks::Context instance) which provides pre auth
+ # or auth objects with issued token based on hook type (before or after).
+ #
+ # before_successful_authorization do |controller, context|
+ # Rails.logger.info(controller.request.params.inspect)
+ #
+ # Rails.logger.info(context.pre_auth.inspect)
+ # end
+ #
+ # after_successful_authorization do |controller, context|
+ # controller.session[:logout_urls] <<
+ # Doorkeeper::Application
+ # .find_by(controller.request.params.slice(:redirect_uri))
+ # .logout_uri
+ #
+ # Rails.logger.info(context.auth.inspect)
+ # Rails.logger.info(context.issued_token)
+ # end
+
+ # Under some circumstances you might want to have applications auto-approved,
+ # so that the user skips the authorization step.
+ # For example if dealing with a trusted application.
+ #
+ skip_authorization do |_resource_owner, client|
+ client.application.first_party?
+ end
+
+ # Configure custom constraints for the Token Introspection request.
+ # By default this configuration option allows to introspect a token by another
+ # token of the same application, OR to introspect the token that belongs to
+ # authorized client (from authenticated client) OR when token doesn't
+ # belong to any client (public token). Otherwise requester has no access to the
+ # introspection and it will return response as stated in the RFC.
+ #
+ # Block arguments:
+ #
+ # @param token [Doorkeeper::AccessToken]
+ # token to be introspected
+ #
+ # @param authorized_client [Doorkeeper::Application]
+ # authorized client (if request is authorized using Basic auth with
+ # Client Credentials for example)
+ #
+ # @param authorized_token [Doorkeeper::AccessToken]
+ # Bearer token used to authorize the request
+ #
+ # In case the block returns `nil` or `false` introspection responses with 401 status code
+ # when using authorized token to introspect, or you'll get 200 with { "active": false } body
+ # when using authorized client to introspect as stated in the
+ # RFC 7662 section 2.2. Introspection Response.
+ #
+ # Using with caution:
+ # Keep in mind that these three parameters pass to block can be nil as following case:
+ # `authorized_client` is nil if and only if `authorized_token` is present, and vice versa.
+ # `token` will be nil if and only if `authorized_token` is present.
+ # So remember to use `&` or check if it is present before calling method on
+ # them to make sure you doesn't get NoMethodError exception.
+ #
+ # You can define your custom check:
+ #
+ # allow_token_introspection do |token, authorized_client, authorized_token|
+ # if authorized_token
+ # # customize: require `introspection` scope
+ # authorized_token.application == token&.application ||
+ # authorized_token.scopes.include?("introspection")
+ # elsif token.application
+ # # `protected_resource` is a new database boolean column, for example
+ # authorized_client == token.application || authorized_client.protected_resource?
+ # else
+ # # public token (when token.application is nil, token doesn't belong to any application)
+ # true
+ # end
+ # end
+ #
+ # Or you can completely disable any token introspection:
+ #
+ # allow_token_introspection false
+ #
+ # If you need to block the request at all, then configure your routes.rb or web-server
+ # like nginx to forbid the request.
+
+ # WWW-Authenticate Realm (default: "Doorkeeper").
+ #
+ # realm "Doorkeeper"
+end
diff --git a/config/initializers/doorkeeper_openid_connect.rb b/config/initializers/doorkeeper_openid_connect.rb
new file mode 100644
index 0000000..113f20e
--- /dev/null
+++ b/config/initializers/doorkeeper_openid_connect.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'myetm/auth'
+
+Doorkeeper::OpenidConnect.configure do
+ issuer do |_resource_owner, _application|
+ Settings.auth.issuer
+ end
+
+ signing_key MyEtm::Auth.signing_key_content
+
+ subject_types_supported [:public]
+
+ resource_owner_from_access_token do |access_token|
+ # Example implementation:
+ User.find_by(id: access_token.resource_owner_id)
+ end
+
+ auth_time_from_resource_owner do |resource_owner|
+ # Example implementation:
+ resource_owner.current_sign_in_at
+ end
+
+ reauthenticate_resource_owner do |resource_owner, return_to|
+ # Example implementation:
+ store_location_for resource_owner, return_to
+ sign_out resource_owner
+ redirect_to new_user_session_url
+ end
+
+ # Depending on your configuration, a DoubleRenderError could be raised
+ # if render/redirect_to is called at some point before this callback is executed.
+ # To avoid the DoubleRenderError, you could add these two lines at the beginning
+ # of this callback: (Reference: https://github.com/rails/rails/issues/25106)
+ # self.response_body = nil
+ # @_response_body = nil
+ select_account_for_resource_owner do |resource_owner, return_to|
+ # Example implementation:
+ # store_location_for resource_owner, return_to
+ # redirect_to account_select_url
+ end
+
+ subject do |resource_owner, _application|
+ # Example implementation:
+ resource_owner.id
+
+ # or if you need pairwise subject identifier, implement like below:
+ # Digest::SHA256.hexdigest("#{resource_owner.id}#{URI.parse(application.redirect_uri).host}#{'your_secret_salt'}")
+ end
+
+ # Protocol to use when generating URIs for the discovery endpoint,
+ # for example if you also use HTTPS in development
+ protocol do
+ Rails.env.development? ? :http : :https
+ end
+
+ # Expiration time on or after which the ID Token MUST NOT be accepted for processing. (default 120 seconds).
+ # expiration 600
+
+ end_session_endpoint do
+ destroy_user_session_url
+ end
+
+ claims do
+ # rubocop:disable Style/SymbolProc
+ normal_claim(:email, response: %i[id_token user_info]) do |resource_owner|
+ resource_owner.email
+ end
+
+ normal_claim(:roles, scope: :roles, response: %i[user_info]) do |resource_owner|
+ resource_owner.roles
+ end
+
+ normal_claim(:name, scope: :profile, response: %i[id_token user_info]) do |resource_owner|
+ resource_owner.name
+ end
+ # rubocop:enable Style/SymbolProc
+ end
+end
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index 3860f65..87a2237 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -14,3 +14,6 @@
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end
+ActiveSupport::Inflector.inflections(:en) do |inflect|
+ inflect.acronym 'OAuth'
+end
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
new file mode 100644
index 0000000..0022f4a
--- /dev/null
+++ b/config/locales/devise.en.yml
@@ -0,0 +1,103 @@
+# Additional translations at https://github.com/heartcombo/devise/wiki/I18n
+
+en:
+ devise:
+ links:
+ sign_in: Sign in
+ sign_up: Sign up
+ forgot_password: Forgotten your password?
+ resend_confirmation: Didn't receive confirmation instructions?
+ unlock: Didn't receive unlock instructions?
+ omniauth: Sign in with %{kind}
+ confirmations:
+ confirmed: "Your email address has been successfully confirmed."
+ send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
+ new:
+ title: Resend confirmation instructions
+ subtitle: An email with instructions for how to confirm your account will be sent to the address below
+ submit: Send confirmation instructions
+ failure:
+ already_authenticated: "You are already signed in."
+ inactive: "Your account is not activated yet."
+ invalid: "Invalid %{authentication_keys} or password."
+ locked: "Your account is locked."
+ last_attempt: "You have one more attempt before your account is locked."
+ not_found_in_database: "Invalid %{authentication_keys} or password."
+ timeout: "Your session expired. Please sign in again to continue."
+ unauthenticated: "You need to sign in or sign up before continuing."
+ unconfirmed: "You have to confirm your email address before continuing."
+ mailer:
+ confirmation_instructions:
+ subject: "Confirmation instructions"
+ reset_password_instructions:
+ subject: "Reset password instructions"
+ unlock_instructions:
+ subject: "Unlock instructions"
+ email_changed:
+ subject: "Email Changed"
+ password_change:
+ subject: "Password Changed"
+ omniauth_callbacks:
+ failure: "Could not authenticate you from %{kind} because \"%{reason}\"."
+ success: "Successfully authenticated from %{kind} account."
+ passwords:
+ no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+ send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
+ send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+ updated: "Your password has been changed successfully. You are now signed in."
+ updated_not_active: "Your password has been changed successfully."
+ new:
+ title: Reset your password
+ subtitle: Enter your e-mail address and we'll send you a link to reset your password
+ submit: Reset my password
+ edit:
+ title: Change your password
+ subtitle: Choose a new password to recover your account with the Energy Transition Model
+ submit: Change password
+ password: New password
+ password_confirmation: Re-enter new password
+ registrations:
+ destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon."
+ signed_up: "Welcome! You have signed up successfully."
+ signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated."
+ signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked."
+ signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
+ update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address."
+ updated: "Your account has been updated successfully."
+ updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again."
+ new:
+ title: Create an account
+ subtitle: Sign up to the Energy Transition Model to continue
+ submit: Sign up
+ sessions:
+ signed_in:
+ title: Welcome!
+ message: You have signed in to your account.
+ signed_out:
+ title: Goodbye
+ message: You have signed out of your account.
+ already_signed_out:
+ title: Goodbye
+ message: You have signed out of your account.
+ new:
+ title: Welcome
+ subtitle: Sign in to your account to continue to the Energy Transition Model
+ dont_have_account: Don't have an account?
+ forgot_password: Forgotten your password?
+ sign_up: Sign up
+ submit: Continue
+ unlocks:
+ send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
+ send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
+ unlocked: "Your account has been unlocked successfully. Please sign in to continue."
+ errors:
+ messages:
+ already_confirmed: "was already confirmed, please try signing in"
+ confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one"
+ expired: "has expired, please request a new one"
+ not_found: "not found"
+ not_locked: "was not locked"
+ not_saved:
+ one: "1 error prohibited this %{resource} from being saved:"
+ other: "%{count} errors prohibited this %{resource} from being saved:"
diff --git a/config/locales/devise.nl.yml b/config/locales/devise.nl.yml
new file mode 100644
index 0000000..ff46aa8
--- /dev/null
+++ b/config/locales/devise.nl.yml
@@ -0,0 +1,160 @@
+nl:
+ activerecord:
+ attributes:
+ user:
+ confirmation_sent_at: Bevestiging verstuurd op
+ confirmation_token: Bevestigingstoken
+ confirmed_at: Bevestigd op
+ created_at: Aangemaakt op
+ current_password: Huidig wachtwoord
+ current_sign_in_at: Huidige login op
+ current_sign_in_ip: Huidige login IP
+ email: E-mailadres
+ encrypted_password: Versleuteld wachtwoord
+ failed_attempts: Mislukte pogingen
+ last_sign_in_at: Laatste login op
+ last_sign_in_ip: Laatste login IP
+ locked_at: Vergrendeld op
+ password: Wachtwoord
+ password_confirmation: Bevestig wachtwoord
+ remember_created_at: Herinneren aangemaakt op
+ remember_me: Ingelogd blijven
+ reset_password_sent_at: Reset wachtwoord verstuurd op
+ reset_password_token: Reset wachtwoord token
+ sign_in_count: Aantal logins
+ unconfirmed_email: Onbevestigd e-mailadres
+ unlock_token: Ontgrendel token
+ updated_at: Bijgewerkt op
+ models:
+ user:
+ one: Gebruiker
+ other: Gebruikers
+ devise:
+ confirmations:
+ confirmed: Je account is bevestigd.
+ new:
+ resend_confirmation_instructions: Verstuur bevestiging-instructies opnieuw
+ send_instructions: Je ontvangt via e-mail instructies hoe je je account kan bevestigen.
+ send_paranoid_instructions: Als we je e-mailadres terugvinden in onze database, zal je binnen enkele ogenblikken een e-mail ontvangen met instructies hoe je je account kan bevestigen.
+ failure:
+ already_authenticated: Je bent reeds aangemeld.
+ inactive: Je account is nog niet geactiveerd.
+ invalid: Ongeldig %{authentication_keys} of wachtwoord.
+ last_attempt: Je hebt nog één poging voordat je account vergrendeld wordt.
+ locked: Je account is vergrendeld.
+ not_found_in_database: Ongeldig %{authentication_keys} of wachtwoord.
+ timeout: Je sessie is verlopen, meld je opnieuw aan om door te gaan.
+ unauthenticated: Je dient je aan te melden of in te schrijven om door te gaan.
+ unconfirmed: Je dient eerst je account te bevestigen.
+ mailer:
+ confirmation_instructions:
+ action: Bevestig mijn account
+ greeting: Welkom %{recipient}!
+ instruction: 'Je kunt je e-mailadres bevestigen via onderstaande link:'
+ subject: Bevestiging
+ email_changed:
+ greeting: Beste %{recipient}!
+ message: Het e-mailadres van uw account is gewijzigd in %{email}.
+ message_unconfirmed: We nemen contact op om je te laten weten dat je emailadres is gewijzigd naar %{email}.
+ subject: E-mailadres veranderd
+ password_change:
+ greeting: Hallo %{recipient}!
+ message: We stellen je met deze e-mail ervan op de hoogte dat je wachtwoord is gewijzigd.
+ subject: Je wachtwoord is gewijzigd
+ reset_password_instructions:
+ action: Verander mijn wachtwoord
+ greeting: Hallo %{recipient}!
+ instruction: Iemand heeft een link gevraagd om uw wachtwoord te wijzigen; dit kan via onderstaande link.
+ instruction_2: Als je dit niet zelf hebt gevraagd, negeer deze mail dan alsjeblieft.
+ instruction_3: Je wachtwoord zal niet wijzigen totdat je bovenstaande link hebt gevolgd en een nieuw wachtwoord hebt aangemaakt.
+ subject: Instructies om je wachtwoord opnieuw in te stellen
+ unlock_instructions:
+ action: Ontgrendel mijn account
+ greeting: Hallo %{recipient}!
+ instruction: 'Je kunt je account ontgrendelen via onderstaande link:'
+ message: Je account is vergrendeld door te veel mislukte pogingen om in te loggen.
+ subject: Ontgrendelinstructies
+ omniauth_callbacks:
+ failure: We konden je niet aanmelden op je %{kind} omdat "%{reason}".
+ success: Je bent succesvol ingelogd op je %{kind} account.
+ passwords:
+ new:
+ title: Reset je wachtwoord
+ subtitle: Voer je e-mailadres in en we sturen je een link om je wachtwoord te resetten
+ submit: Reset je wachtwoord
+ edit:
+ title: Wijzig je wachtwoord
+ subtitle: Kies een nieuw wachtwoord om je account op het Energietransitiemodel te herstellen
+ submit: Wijzig wachtwoord
+ password: Nieuw wachtwoord
+ password_confirmation: Voer nieuw wachtwoord nogmaals in
+ no_token: Deze pagina is alleen bereikbaar via een wachtwoord vergeten e-mail. Als je wel via een wachtwoord vergeten e-mail komt, zorg er dan voor dat je de volledige URL gebruikt.
+ send_instructions: Je ontvangt een e-mail met instructies hoe je je wachtwoord opnieuw kan instellen.
+ send_paranoid_instructions: Als we je e-mailadres terugvinden in onze database, zal je binnen enkele ogenblikken via e-mail een link ontvangen om je wachtwoord opnieuw in te stellen.
+ updated: Je wachtwoord is gewijzigd. Je bent nu aangemeld.
+ updated_not_active: Je wachtwoord is met succes gewijzigd.
+ registrations:
+ destroyed: Je account is verwijderd, wellicht tot ziens!
+ edit:
+ are_you_sure: Weet je het zeker?
+ cancel_my_account: Annuleer mijn account
+ currently_waiting_confirmation_for_email: 'Aan het wachten op de bevestiging voor: %{email}'
+ leave_blank_if_you_don_t_want_to_change_it: laat leeg als je het niet wilt wijzigen
+ title: Bewerk %{resource}
+ unhappy: Niet blij
+ update: Bijwerken
+ we_need_your_current_password_to_confirm_your_changes: we hebben je huidige wachtwoord nodig om je veranderingen te bevestigen
+ new:
+ sign_up: Maak account aan
+ signed_up: Je bent ingeschreven.
+ signed_up_but_inactive: Je bent ingeschreven, maar we konden je niet inloggen omdat je account nog niet is geactiveerd.
+ signed_up_but_locked: Je bent ingeschreven, maar we konden je niet inloggen omdat je account is vergrendeld.
+ signed_up_but_unconfirmed: Een e-mail met een confirmatielink is naar je e-mailadres gestuurd. Open de link in je browser om je account te activeren.
+ update_needs_confirmation: Je account is bijgewerkt, maar we moeten je e-mailadres nog valideren. Een e-mail met een confirmatielink is naar je e-mailadres gestuurd. Open de link in je browser om je e-mailadres te confirmeren.
+ updated: Je accountgegevens zijn opgeslagen.
+ updated_but_not_signed_in: Je account is succesvol bijgewerkt, maar omdat je wachtwoord is aangepast moet je opnieuw inloggen.
+ sessions:
+ new:
+ title: Welkom
+ subtitle: Log in om verder te gaan naar het Energietransitiemodel
+ dont_have_account: Nog geen account?
+ forgot_password: Wachtwoord vergeten?
+ sign_up: Account aanmaken
+ submit: Doorgaan
+ signed_in:
+ title: Welkom!
+ message: Je bent succesvol ingelogd.
+ signed_out:
+ title: Vaarwel!
+ message: Je bent succesvol uitgelogd.
+ already_signed_out:
+ title: Vaarwel!
+ message: Je bent succesvol uitgelogd.
+ shared:
+ links:
+ back: Terug
+ didn_t_receive_confirmation_instructions: Geen bevestigingsmail ontvangen?
+ didn_t_receive_unlock_instructions: Geen ontgrendel-instructies ontvangen?
+ forgot_your_password: Wachtwoord vergeten?
+ sign_in: Inloggen
+ sign_in_with_provider: Inloggen met %{provider}
+ sign_up: Maak account aan
+ minimum_password_length:
+ one: "(minimaal %{count} karakter)"
+ other: "(minimaal %{count} karakters)"
+ unlocks:
+ new:
+ resend_unlock_instructions: Verstuur ontgrendel-instructies opnieuw
+ send_instructions: Je ontvangt via e-mail instructies hoe je je account kan ontgrendelen.
+ send_paranoid_instructions: Als we je e-mailadres terugvinden in onze database, zal je binnen enkele ogenblikken een e-mail ontvangen met instructies hoe je je account kan ontgrendelen.
+ unlocked: Je account is ontgrendeld. Je kan nu weer inloggen.
+ errors:
+ messages:
+ already_confirmed: is reeds bevestigd
+ confirmation_period_expired: moet binnen %{period} worden bevestigd, plaats a.u.b. een nieuw verzoek
+ expired: is verlopen, vraag een nieuwe aan
+ not_found: niet gevonden
+ not_locked: is niet gesloten
+ not_saved:
+ one: 'Een fout verhinderde het opslaan van deze %{resource}:'
+ other: "%{count} fouten verhinderde het opslaan van deze %{resource}:"
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
new file mode 100644
index 0000000..99fa3d4
--- /dev/null
+++ b/config/locales/doorkeeper.en.yml
@@ -0,0 +1,151 @@
+en:
+ activerecord:
+ attributes:
+ doorkeeper/application:
+ name: 'Name'
+ redirect_uri: 'Redirect URI'
+ errors:
+ models:
+ doorkeeper/application:
+ attributes:
+ redirect_uri:
+ fragment_present: 'cannot contain a fragment.'
+ invalid_uri: 'must be a valid URI.'
+ unspecified_scheme: 'must specify a scheme.'
+ relative_uri: 'must be an absolute URI.'
+ secured_uri: 'must be an HTTPS/SSL URI.'
+ forbidden_uri: 'is forbidden by the server.'
+ scopes:
+ not_match_configured: "doesn't match configured on the server."
+
+ doorkeeper:
+ applications:
+ confirmations:
+ destroy: 'Are you sure?'
+ buttons:
+ edit: 'Edit'
+ destroy: 'Destroy'
+ submit: 'Submit'
+ cancel: 'Cancel'
+ authorize: 'Authorize'
+ form:
+ error: 'Whoops! Check your form for possible errors'
+ help:
+ confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'
+ redirect_uri: 'Use one line per URI'
+ blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI."
+ scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'
+ edit:
+ title: 'Edit application'
+ index:
+ title: 'Your applications'
+ new: 'New Application'
+ name: 'Name'
+ callback_url: 'Callback URL'
+ confidential: 'Confidential?'
+ actions: 'Actions'
+ confidentiality:
+ 'yes': 'Yes'
+ 'no': 'No'
+ new:
+ title: 'New Application'
+ show:
+ title: 'Application: %{name}'
+ application_id: 'UID'
+ secret: 'Secret'
+ secret_hashed: 'Secret hashed'
+ scopes: 'Scopes'
+ confidential: 'Confidential'
+ callback_urls: 'Callback urls'
+ actions: 'Actions'
+ not_defined: 'Not defined'
+
+ authorizations:
+ buttons:
+ authorize: 'Authorize'
+ deny: 'Deny'
+ error:
+ title: 'An error has occurred'
+ new:
+ title: 'Authorization required'
+ prompt: 'Authorize %{client_name} to use your account?'
+ able_to: 'This application will be able to'
+ show:
+ title: 'Authorization code'
+ form_post:
+ title: 'Submit this form'
+
+ authorized_applications:
+ confirmations:
+ revoke: 'Are you sure?'
+ buttons:
+ revoke: 'Revoke'
+ index:
+ title: 'Your authorized applications'
+ application: 'Application'
+ created_at: 'Created At'
+ date_format: '%Y-%m-%d %H:%M:%S'
+
+ pre_authorization:
+ status: 'Pre-authorization'
+
+ errors:
+ messages:
+ # Common error messages
+ invalid_request:
+ unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'
+ missing_param: 'Missing required parameter: %{value}.'
+ request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'
+ invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI."
+ unauthorized_client: 'The client is not authorized to perform this request using this method.'
+ access_denied: 'The resource owner or authorization server denied the request.'
+ invalid_scope: 'The requested scope is invalid, unknown, or malformed.'
+ invalid_code_challenge_method: 'The code challenge method must be plain or S256.'
+ server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
+ temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
+
+ # Configuration error messages
+ credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
+ resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'
+ admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'
+
+ # Access grant errors
+ unsupported_response_type: 'The authorization server does not support this response type.'
+ unsupported_response_mode: 'The authorization server does not support this response mode.'
+
+ # Access token errors
+ invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'
+ invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'
+ unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'
+
+ invalid_token:
+ revoked: "The access token was revoked"
+ expired: "The access token expired"
+ unknown: "The access token is invalid"
+ revoke:
+ unauthorized: "You are not authorized to revoke this token"
+
+ forbidden_token:
+ missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".'
+
+ flash:
+ applications:
+ create:
+ notice: 'Application created.'
+ destroy:
+ notice: 'Application deleted.'
+ update:
+ notice: 'Application updated.'
+ authorized_applications:
+ destroy:
+ notice: 'Application revoked.'
+
+ layouts:
+ admin:
+ title: 'Doorkeeper'
+ nav:
+ oauth2_provider: 'OAuth2 Provider'
+ applications: 'Applications'
+ home: 'Home'
+ application:
+ title: 'OAuth authorization required'
diff --git a/config/locales/doorkeeper_openid_connect.en.yml b/config/locales/doorkeeper_openid_connect.en.yml
new file mode 100644
index 0000000..ae3c584
--- /dev/null
+++ b/config/locales/doorkeeper_openid_connect.en.yml
@@ -0,0 +1,24 @@
+en:
+ doorkeeper:
+ scopes:
+ openid: 'Authenticate your account'
+ profile: 'View your profile information'
+ email: 'View your email address'
+ address: 'View your physical address'
+ phone: 'View your phone number'
+ roles: 'View your roles'
+ errors:
+ messages:
+ login_required: 'The authorisation server requires end-user authentication'
+ consent_required: 'The authorisation server requires end-user consent'
+ interaction_required: 'The authorisation server requires end-user interaction'
+ account_selection_required: 'The authorisation server requires end-user account selection'
+ openid_connect:
+ errors:
+ messages:
+ # Configuration error messages
+ resource_owner_from_access_token_not_configured: 'Failure due to Doorkeeper::OpenidConnect.configure.resource_owner_from_access_token missing configuration.'
+ auth_time_from_resource_owner_not_configured: 'Failure due to Doorkeeper::OpenidConnect.configure.auth_time_from_resource_owner missing configuration.'
+ reauthenticate_resource_owner_not_configured: 'Failure due to Doorkeeper::OpenidConnect.configure.reauthenticate_resource_owner missing configuration.'
+ select_account_for_resource_owner_not_configured: 'Failure due to Doorkeeper::OpenidConnect.configure.select_account_for_resource_owner missing configuration.'
+ subject_not_configured: 'ID Token generation failed due to Doorkeeper::OpenidConnect.configure.subject missing configuration.'
diff --git a/config/locales/doorkeeper_openid_connect.nl.yml b/config/locales/doorkeeper_openid_connect.nl.yml
new file mode 100644
index 0000000..32fb0ec
--- /dev/null
+++ b/config/locales/doorkeeper_openid_connect.nl.yml
@@ -0,0 +1,9 @@
+nl:
+ doorkeeper:
+ scopes:
+ openid: 'Authenticeer je account'
+ profile: 'Bekijk je profielinformatie'
+ email: 'Bekijk je e-mailadres'
+ address: 'Bekijk je fysieke adres'
+ phone: 'Bekijk je telefoonnummer'
+ roles: 'Bekijk je rollen'
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1767b52..e03a216 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -28,6 +28,40 @@
# enabled: "ON"
en:
+ time:
+ formats:
+ short: "%-d %b %H:%M"
+ long: "%A %-d %B, %Y at %H:%M"
+ time: "%H:%M"
+ only_second: "%S"
+ date: "%A %-d %B, %Y"
+ date:
+ formats:
+ short: "%-d %b"
+ long: "%A %-d %B, %Y"
+ date: "%A %-d %B, %Y"
+ helpers:
+ label:
+ create_personal_access_token_params:
+ name: Token name
+ expires_in: Expiration
+ permissions: Permissions
+ activerecord:
+ attributes:
+ user:
+ email: E-mail address
+ password: Password
+ password_confirmation: Re-enter password
+ current_password: Current password
+ unconfirmed_email: New e-mail address
+ name: Your name
+ scenario_invitation_mailer:
+ invite_user:
+ subject: 'Invitation: ETM scenario'
+ roles:
+ scenario_viewer: viewer
+ scenario_collaborator: collaborator
+ scenario_owner: owner
sidebar:
saved_scenarios: Scenarios
collections: Collections
diff --git a/config/locales/identity.en.yml b/config/locales/identity.en.yml
new file mode 100644
index 0000000..06d9a8f
--- /dev/null
+++ b/config/locales/identity.en.yml
@@ -0,0 +1,265 @@
+---
+en:
+ select_language: Language
+ time_from_now: '%{time} from now'
+ time_ago: '%{time} ago'
+
+ identity:
+ cancel: Cancel
+ return_to_etm: Return to the ETM
+ profile_email_component:
+ change_address: Change e-mail address
+ confirmed: Verified
+ explanation: This address is used to sign in to your account, and to send you important notifications.
+ not_confirmed: Not verified
+ not_confirmed_message: |
+ This e-mail address has not yet been verified. Please check your e-mail address for
+ instructions.
+ resend_confirmation: Resend confirmation instructions
+ sign_out: Sign out
+ site_title: Energy Transition Model
+
+ # Pages
+
+ authorized_application_component:
+ permissions: Permissions
+ revoke_application: Revoke application
+ visit_website: Visit website
+
+ authorized_applications:
+ index:
+ empty_state:
+ title: You have no authorised third-party applications
+ message: |
+ You can authorise third-parties to access data from your account without providing them
+ your password. These will ask for permission to access your account, after which they
+ will be listed here.
+
+ You may revoke these permissions at any time.
+ explanation: Control who can access your account and data.
+ title: Authorised applications
+
+ settings:
+ edit_email:
+ new_email: New e-mail address
+ new_email_hint: A confirmation will be sent to this address before the change takes effect
+ submit: Change e-mail address
+ title: Change your e-mail address
+ edit_name:
+ new_name: New name
+ new_name_hint: |
+ This name will be used when we communicate with you, and alongside your public scenarios.
+ submit: Change name
+ title: Change your account name
+ edit_password:
+ current_password: Current password
+ current_password_hint: Confirm the password you currently use to sign in
+ new_password: New password
+ new_password_hint: |
+ Your password needs to be at least %{min_length} characters long. Use multiple words or
+ phrases, numbers, or symbols to make it more secure.
+ submit: Change password
+ title: Change your password
+ index:
+ account: Account
+ change_name: Change name
+ change_password: Change password
+ explanation: Change your e-mail address, password, or account settings.
+ name_explanation: This name will be shown on your public scenarios.
+ password_explanation_html: |
+ Your password is used to sign in. Never give this to anyone else: if another application
+ needs to access data on your account,
+ create a personal access token.
+ profile: Profile
+ settings: Settings
+ title: Your profile
+ privacy:
+ title: Privacy
+ choose: |
+ Choose whether new scenarios and saved scenarios should be private or public by default.
+ You can override this on a per-scenario basis.
+ public_vs_private: |
+ Public scenarios can be viewed (but not changed) by anyone with a link to the scenario,
+ while private scenarios can be viewed only by you.
+ update_email:
+ success:
+ title: E-mail changed
+ message: Please check your inbox to confirm the change of e-mail address.
+ update_name:
+ success:
+ title: Name changed
+ message: The name of your account was successfully updated.
+ update_password:
+ success:
+ title: Password changed
+ message: Your password was successfully updated.
+ update_preference:
+ saved: Saved
+ newsletter_subscribed:
+ title: Subscribed to newsletter
+ message: Please check your e-mail inbox to confirm your subscription.
+ newsletter_unsubscribed:
+ title: Unsubscribed from newsletter
+ message: You have been unsubscribed from our newsletter.
+
+ newsletter_status_row_component:
+ subscribed: Subscribed
+ not_subscribed: Not subscribed
+ title: E-mail newsletter
+ unsubscribe: Unsubscribe
+ not_subscribed_message: |
+ We occasionally publish a newsletter with information about improvements and changes to
+ the ETM. We send roughly four messages a year and you can unsubscribe at any time.
+ subscribe: Subscribe
+ subscribed_message: |
+ You are subscribed to our newsletter. We send roughly four e-mails a year with information
+ about improvements and changes to the model.
+
+ token_component:
+ copied: Copied!
+ copy: Copy token to clipboard
+ expires: Expires
+ last_used: Last used
+ permissions: Permissions
+ full_token_not_shown: |
+ The full token is not shown here. If you have lost it you should revoke this token and
+ create a new one.
+ make_sure_to_copy: Make sure to copy it to your clipboard.
+ revoke_token: Revoke token
+ revoke_warning: |
+ Revoking the token will immediately prevent it from being used to access or modify your
+ account.
+ token: Token
+ token_shown_briefly: The full token will only be shown briefly!
+
+ token_mailer:
+ created_token:
+ subject: You created a new token
+ expiring_token:
+ subject: Your personal access token will expire soon
+
+ tokens:
+ empty_state:
+ title: You have no access tokens
+ message: |
+ Access tokens allow third-party applications to access your account without having to
+ use your password. You can revoke access tokens at any time.
+ index:
+ create_token: Create token
+ explanation: Generate personal tokens to use the API.
+ learn_more: Learn more
+ title: Personal access tokens
+ created:
+ title: Token created
+ message: Your new token has been created. Please copy it now as it will not be shown again.
+ revoked:
+ title: Token revoked
+ message: The token can no longer be used to access the API.
+ new:
+ name: Token name
+ name_hint: This name will help you recognise the token in the future.
+ permissions: Scenario permissions
+ title: Create a new access token
+ permissions:
+ public:
+ title: Read public scenarios only
+ hint: |
+ This token will be able to read public scenarios created by you and other people.
+ read:
+ title: Read public and private scenarios
+ hint: |
+ This token will be able to read your public and private scenarios, but will not permit
+ changes.
+ write:
+ title: Read and write public and private scenarios
+ hint: |
+ This token will be able to read and make changes to your public and private scenarios,
+ but will not be able to delete scenarios.
+ delete:
+ title: Read, write, and delete public and private scenarios
+ hint: |
+ This token will be able to read and make changes to your public and private scenarios,
+ and also delete your scenarios.
+ email:
+ title: E-mail address
+ hint: |
+ The token will be able to read your e-mail address.
+ profile:
+ title: Profile
+ hint: |
+ The token will be able to read your profile information including
+ your name.
+ expiration_options:
+ never: Never
+ n_days: "%{n} days"
+ one_year: One year
+ never_message: This token will never expire!
+ expires_at_message: This token will expire on %{date}.
+
+ doorkeeper:
+ authorizations:
+ buttons:
+ authorize: Authorise
+ deny: Deny
+ error:
+ title: An error has occurred
+ new:
+ title: Authorisation required
+ prompt: Authorise %{client_name} to use your account?
+ able_to: This application will be able to
+ show:
+ title: Authorisation code
+ form_post:
+ title: Submit this form
+ flash:
+ authorized_applications:
+ destroy:
+ notice:
+ title: Application revoked
+ message: The application no longer has access to your account.
+ scopes:
+ public: Read public scenarios
+ email: See your e-mail address
+ profile: See your profile information
+ "scenarios:read": Read your private scenarios
+ "scenarios:write": Create new scenarios and change your public and private scenarios
+ "scenarios:delete": Delete your public and private scenarios
+ devise:
+ registrations:
+ confirm_destroy:
+ title: Delete account
+ submit: Permanently delete account
+ information: |
+ If you no longer wish to be a member of the Energy Transition Model, you may delete your
+ account. You'll be prompted for confirmation on the next page.
+ warning_header: You are about to delete your account!
+ warning: |
+ All of your data – including your name and e-mail address, scenarios, and transition paths
+ – will be deleted. If you have shared scenarios with other users, they will no longer be
+ able to access them.
+ irreversible: This is irreversible!
+ confirm: |
+ Please enter your password to confirm you want to remove your account.
+ will_remove: 'Deleting your account will remove:'
+ entities:
+ data: Your account, personal data, and preferences
+ scenario:
+ zero: No scenarios
+ one: One scenario
+ other: '%{count} scenarios'
+ saved_scenario:
+ zero: No saved scenarios
+ one: One saved scenario
+ other: '%{count} saved scenarios'
+ oauth_application:
+ zero: No OAuth applications
+ one: One OAuth application
+ other: '%{count} OAuth applications'
+ transition_path:
+ zero: No transition paths
+ one: One transition path
+ other: '%{count} transition paths'
+ personal_access_token:
+ zero: No personal access tokens
+ one: One personal access token
+ other: '%{count} personal access tokens'
diff --git a/config/locales/identity.nl.yml b/config/locales/identity.nl.yml
new file mode 100644
index 0000000..ae71350
--- /dev/null
+++ b/config/locales/identity.nl.yml
@@ -0,0 +1,256 @@
+---
+nl:
+ select_language: Taal
+ time_from_now: '%{time} from now'
+ time_ago: '%{time} ago'
+
+ identity:
+ cancel: Annuleren
+ return_to_etm: Ga terug naar het ETM
+ profile_email_component:
+ change_address: Wijzig e-mailadres
+ confirmed: Bevestigd
+ explanation: Dit adres wordt gebuikt om in te loggen en om je belangrijke notificaties te sturen.
+ not_confirmed: Niet bevestigd
+ not_confirmed_message: |
+ Dit e-mailadres is nog niet bevestigd. Controleer je e-mailadres op instructies.
+ resend_confirmation: Stuur nieuwe instructies ter bevestiging
+ sign_out: Uitloggen
+ site_title: Energietransitiemodel
+
+ # Pages
+
+ authorized_application_component:
+ permissions: Bevoegdheden
+ revoke_application: Applicatie intrekken
+ visit_website: Bezoek website
+
+ authorized_applications:
+ index:
+ empty_state:
+ title: Je hebt geen applicaties van derden toestemming gegeven
+ message: |
+ Je kunt derden toestemming geven om gegevens van je account op te vragen, zonder je
+ wachtwoord te delen. Deze partijen zullen toestemming vragen om toegang te krijgen tot
+ je account, waarna ze hier worden weergegeven.
+
+ Je kunt deze toestemming op elk moment intrekken.
+ explanation: Beheer wie toegang heeft tot je account en gegevens.
+ title: Geautoriseerde applicaties
+
+ settings:
+ edit_email:
+ new_email: Nieuw e-mailadres
+ new_email_hint: Er wordt een bevestiging naar dit adres verzonden voor de wijziging actief wordt
+ submit: Wijzig e-mailadres
+ title: Wijzig je e-mailadres
+ edit_name:
+ new_name: Nieuwe naam
+ new_name_hint: |
+ Deze naam gebruiken we in onze communicatie naar jou en wordt weergegeven bij jouw openbare scenario's.
+ submit: Wijzig naam
+ title: Wijzig je accountnaam
+ edit_password:
+ current_password: Huidig wachtwoord
+ current_password_hint: Bevestig het wachtwoord dat je momenteel gebruikt om in te loggen
+ new_password: Nieuw wachtwoord
+ new_password_hint: |
+ Je wachtwoord moet ten minste %{min_length} tekens lang zijn. Gebruik meerdere woorden,
+ cijfers of symbolen om het beter te beveiligen.
+ submit: Wijzig wachtwoord
+ title: Wijzig je wachtwoord
+ index:
+ account: Account
+ change_name: Wijzig naam
+ change_password: Wijzig wachtwoord
+ explanation: Wijzig je e-mailadres, wachtwoord of accountinstellingen.
+ name_explanation: Deze naam wordt weergegeven bij jouw openbare scenario's.
+ password_explanation: |
+ Dit wachtwoord wordt gebruikt om in te loggen. Deel dit wachtwoord nooit met iemand anders:
+ als een andere applicatie toegang nodig heeft tot data van jouw account, maak dan een
+ persoonlijk toegangstoken aan.
+ profile: Profiel
+ settings: Settings
+ title: Jouw profiel
+ privacy:
+ title: Privacy
+ choose: |
+ Stel in of nieuwe scenario's en opgeslagen scenario's standaard privé of publiek zijn.
+ Je kunt deze instelling per scenario overschrijven.
+ public_vs_private: |
+ Publieke scenarios kunnen door iedereen met een link naar het scenario worden bekeken
+ (maar niet worden gewijzigd), terwijl privéscenario's alleen door jou kunnen worden bekeken.
+ update_email:
+ success:
+ title: E-mail gewijzigd
+ message: Controleer je inbox om de wijziging van het e-mailadres te bevestigen.
+ update_name:
+ success:
+ title: Naam gewijzigd
+ message: De naam van je account is succesvol bijgewerkt.
+ update_password:
+ success:
+ title: Wachtwoord gewijzigd
+ message: Je wachtwoord is succesvol bijgewerkt.
+ update_preference:
+ saved: Opgeslagen
+ newsletter_subscribed:
+ title: Geabonneerd op de nieuwsbrief
+ message: Kijk in je mailbox om je inschrijving te bevestigen.
+ newsletter_unsubscribed:
+ title: Afgemeld voor de nieuwsbrief
+ message: Je hebt je uitgeschreven voor onze nieuwsbrief.
+
+ newsletter_status_row_component:
+ title: E-mail nieuwsbrief
+ subscribed: Geabonneerd
+ not_subscribed: Niet geabonneerd
+ unsubscribe: Uitschrijven
+ not_subscribed_message: |
+ We versturen ongeveer vier keer per jaar een nieuwsbrief met informatie over verbeteringen
+ en wijzigingen aan het ETM. Je kan je op elk moment uitschrijven.
+ subscribe: Inschrijven
+ subscribed_message: |
+ Je bent ingeschreven voor onze nieuwsbrief. We versturen ongeveer vier e-mails per jaar
+ met verbeteringen en wijzigingen aan het model.
+
+ token_component:
+ copied: Gekopieerd!
+ copy: Kopieer token naar klembord
+ expires: Verloopt
+ last_used: Laatst gebruikt
+ permissions: Bevoegdheden
+ full_token_not_shown: |
+ Het volledige token wordt hier niet getoond. Als dit verloren is gegaan, moet je dit token
+ intrekken en een nieuwe aanmaken.
+ make_sure_to_copy: Zorg dat je het kopieert naar het klembord.
+ revoke_token: Token intrekken
+ revoke_warning: |
+ Als je het token intrekt, verliest dit onmiddelijk de mogelijkheid om toegang te krijgen
+ tot jouw account of wijzigingen te maken.
+ token: Token
+ token_shown_briefly: Het volledige token wordt slechts kortstondig getoond!
+
+ tokens:
+ empty_state:
+ title: Je hebt geen toegangstokens
+ message: |
+ Toegangstokens stellen derden in staat om toegang te krijgen tot jouw account zonder je
+ wachtwoord te hoeven gebruiken. Je kunt toegangstokens op elk moment intrekken.
+ index:
+ create_token: Maak een toegangstoken aan
+ explanation: Maak persoonlijke tokens aan om de API te gebruiken.
+ learn_more: Meer informatie
+ title: Persoonlijke toegangstokens
+ created:
+ title: Token aangemaakt
+ message: Jouw nieuwe token is aangemaakt. Kopieer dit nu aangezien het niet nogmaals wordt getoond.
+ revoked:
+ title: Token ingetrokken
+ message: Het token kan niet langer worden gebruikt om toegang te krijgen tot de API.
+ new:
+ name: Token naam
+ name_hint: Deze naam helpt je om in de toekomst het token te herkennen.
+ permissions: Bevoegdheden
+ title: Maak een nieuw toegangstoken aan
+ permissions:
+ public:
+ title: Alleen-lezen van publieke scenario's
+ hint: |
+ Dit token kan enkel publieke scenario's lezen die door jou en anderen zijn gemaakt.
+ read:
+ title: Alleen-lezen van publieke en privéscenario's
+ hint: |
+ Dit token kan jouw publieke en privéscenario's lezen, maar kan geen wijzigingen aanbrengen.
+ write:
+ title: Lees en bewerk publieke en privéscenario's
+ hint: |
+ Dit token kan jouw publieke en privéscenario's lezen en kan wijzigingen aanbrengen, maar kan geen
+ scenario's verwijderen.
+ delete:
+ title: Lees, bewerk en verwijder publieke en privéscenario's
+ hint: |
+ Dit token kan jouw publieke en privéscenario's lezen, kan wijzigingen aanbrengen en kan jouw
+ scenario's verwijderen.
+ email:
+ title: E-mailadres
+ hint: |
+ Dit token kan jouw e-mailadres lezen.
+ profile:
+ title: Profiel
+ hint: |
+ Dit token kan jouw profielinformatie lezen, waaronder jouw naam.
+ expiration_options:
+ never: Nooit
+ n_days: "%{n} days"
+ one_year: Één jaar
+ never_message: Dit token vervalt nooit!
+ expires_at_message: Dit token zal vervallen op %{date}.
+
+ doorkeeper:
+ authorizations:
+ buttons:
+ authorize: Authorise
+ deny: Deny
+ error:
+ title: An error has occurred
+ new:
+ title: Authorisation required
+ prompt: Authorise %{client_name} to use your account?
+ able_to: This application will be able to
+ show:
+ title: Authorisation code
+ form_post:
+ title: Submit this form
+ flash:
+ authorized_applications:
+ destroy:
+ notice:
+ title: Application revoked
+ message: The application no longer has access to your account.
+ scopes:
+ public: Lees publieke scenario's
+ email: Bekijk jouw e-mailadres
+ profile: Bekijk jouw profielinformatie
+ "scenarios:read": Lees jouw privéscenario's
+ "scenarios:write": Maak nieuwe scenario's aan en wijzig jouw publieke en privéscenario's
+ "scenarios:delete": Verwijder jouw publieke en privéscenario's
+ devise:
+ registrations:
+ confirm_destroy:
+ title: Account verwijderen
+ submit: Account permanent verwijderen
+ information: |
+ Als u niet langer lid wilt zijn van het Energietransitiemodel, kunt u uw account
+ verwijderen. U wordt om bevestiging gevraagd op de volgende pagina.
+ warning_header: Je staat op het punt om jouw account te verwijderen!
+ warning: |
+ Al jouw data - waaronder jouw naam en e-mailadres, scenario's en transitiepaden - zullen
+ worden verwijderd. Als je gedeelde scenario's hebt met andere gebruikers zullen zij niet
+ langer toegang hebben tot deze scenario's.
+ irreversible: Dit is onomkeerbaar.
+ confirm: |
+ Vul je wachtwoord in om te bevestigen dat je jouw account wilt verwijderen.
+ will_remove: 'Het verwijderen van jouw account wist:'
+ entities:
+ data: Jouw account, persoonlijke data en voorkeuren
+ scenario:
+ zero: Geen scenario's
+ one: Één scenario
+ other: "%{count} scenario's"
+ saved_scenario:
+ zero: Geen opgeslagen scenario's
+ one: Één opgeslagen scenario
+ other: "%{count} opgeslagen scenario's"
+ oauth_application:
+ zero: Geen OAuth applicaties
+ one: Één OAuth applicatie
+ other: '%{count} OAuth applicaties'
+ transition_path:
+ zero: Geen transitiepaden
+ one: Één transitiepaden
+ other: '%{count} transitiepaden'
+ personal_access_token:
+ zero: Geen persoonlijke toegangstokens
+ one: Één persoonlijk toegangstoken
+ other: '%{count} persoonlijke toegangstokens'
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index ffc742d..fa0edd0 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -1,2 +1,35 @@
nl:
- hello: hello
+ time:
+ formats:
+ short: "%-d %b %H:%M"
+ long: "%A %-d %B, %Y at %H:%M"
+ time: "%H:%M"
+ only_second: "%S"
+ date: "%A %-d %B, %Y"
+ date:
+ formats:
+ short: "%-d %b"
+ long: "%A %-d %B, %Y"
+ date: "%A %-d %B, %Y"
+ activemodel:
+ attributes:
+ 'create_personal_access_token/params':
+ name: Token name
+ expires_in: Expiration
+ permissions: Permissions
+ activerecord:
+ attributes:
+ user:
+ email: Emailadres
+ password: Wachtwoord
+ password_confirmation: Voer wachtwoord nogmaals in
+ current_password: Huidig wachtwoord
+ unconfirmed_email: Nieuw e-mailadres
+ name: Jouw naam
+ scenario_invitation_mailer:
+ invite_user:
+ subject: 'Uitnodiging: ETM scenario'
+ roles:
+ scenario_viewer: gast
+ scenario_collaborator: bewerker
+ scenario_owner: eigenaar
diff --git a/config/routes.rb b/config/routes.rb
index 9a9e2ed..2a247b9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,3 +1,5 @@
+require 'sidekiq/web'
+
Rails.application.routes.draw do
resources :saved_scenarios do
member do
@@ -8,6 +10,44 @@
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
+ use_doorkeeper
+ use_doorkeeper_openid_connect
+
+ devise_for :users, path: 'identity', sign_out_via: %i[get post delete], controllers: {
+ sessions: 'users/sessions',
+ registrations: 'users/registrations'
+ }
+
+ authenticate :user, lambda { |u| u.admin? } do
+ mount Sidekiq::Web => '/sidekiq'
+ end
+
+ namespace :identity do
+ get '/', to: redirect('/identity/profile')
+ get 'profile', to: 'settings#index', as: :profile
+
+ get 'change_name', to: 'settings#edit_name', as: :edit_name
+ post 'change_name', to: 'settings#update_name'
+
+ get 'change_email', to: 'settings#edit_email', as: :edit_email
+ post 'change_email', to: 'settings#update_email'
+
+ get 'change_password', to: 'settings#edit_password', as: :edit_password
+ post 'change_password', to: 'settings#update_password'
+
+ post 'change_scenario_privacy', to: 'settings#update_scenario_privacy'
+
+ get 'newsletter', to: 'newsletter#edit', as: :edit_newsletter
+ post 'newsletter', to: 'newsletter#update'
+
+ resources :tokens, only: [:index, :new, :create, :destroy], as: :tokens
+ resources :authorized_applications, only: [:index], as: :authorized_applications
+ end
+
+ devise_scope :user do
+ get 'identity/delete_account', to: 'users/registrations#confirm_destroy', as: :delete_account
+ end
+
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
diff --git a/config/settings.yml b/config/settings.yml
new file mode 100644
index 0000000..1dd3138
--- /dev/null
+++ b/config/settings.yml
@@ -0,0 +1,66 @@
+# Directory where ETSource revisions will be exported.
+etsource_export: "../etsource"
+
+# The path where the ETSource Git repository can be found.
+etsource_working_copy: "../etsource"
+
+# If for some reason you don't want to clear the etsource_export directory
+etsource_disable_export: false
+
+# Enables "live" reloading of the ETSource export. When enabled, changes to datasets/, gqueries/,
+# inputs/, and topology/ will be detected and ETSource will expire its cached copy. The files will
+# be reloaded the on the next request.
+#
+# Note that this setting only applies to the development environment; live reloading is always
+# disabled in production and test environments.
+etsource_live_reload: true
+
+# Enables lazy loading of datasets from Refinery.
+#
+# With this disabled, MyETM will expect that tmp/atlas contains YML files for each region. These
+# are created by Atlas during a production deployment, or when changing the loaded version of
+# ETESource in the admin interface.
+#
+# When enabled, MyETM will load Atlas and Refinery, and calculate the graph in Refinery the first
+# time you load a scenario for the region. This is preferable for development environments, when you
+# don't want to have to worry about creating the YML files manually.
+etsource_lazy_load_dataset: true
+
+# URL to be called after a clean etsource import Usually you would set something like
+# http://beta.et-model.com/pages/refresh_gqueries
+client_refresh_url:
+
+# TODO: FIx this config
+# Optional scheme and hostname for ETEngine which will be used to send requests to ETModel.
+etmodel_uri: localhost:3001
+
+clients:
+ etmodel:
+ id:
+ secret:
+ uri: localhost:3001
+ engine:
+ id:
+ secret:
+ uri: localhost:3000
+
+myetm:
+ iss: my-etm
+ uri: localhost:3002
+
+
+# set to true if the server won't have online access. this disables Sentry,
+# etc.
+standalone: false
+
+mailer:
+ # E-mail address from which to send e-mails.
+ from: "Energy Transition Model "
+
+auth:
+ issuer: <%= ENV.fetch('OPENID_ISSUER', 'http://localhost:3002') %>
+
+# Optional settings to configure the Mailchimp integration.
+mailchimp:
+ list_url: <%= ENV.fetch('MAILCHIMP_LIST_URL', nil) %>
+ api_key: <%= ENV.fetch('MAILCHIMP_API_KEY', nil) %>
diff --git a/db/migrate/20241022090825_create_users_and_authorisation.rb b/db/migrate/20241022090825_create_users_and_authorisation.rb
new file mode 100644
index 0000000..b34996b
--- /dev/null
+++ b/db/migrate/20241022090825_create_users_and_authorisation.rb
@@ -0,0 +1,113 @@
+class CreateUsersAndAuthorisation < ActiveRecord::Migration[7.2]
+ def change
+ # Modify the users table if it already exists
+ if table_exists?(:users)
+ change_table :users, bulk: true do |t|
+ # Add columns only if they don't already exist
+ t.string :encrypted_password, null: false, default: "" unless column_exists?(:users, :encrypted_password)
+ t.string :reset_password_token unless column_exists?(:users, :reset_password_token)
+ t.datetime :reset_password_sent_at unless column_exists?(:users, :reset_password_sent_at)
+ t.datetime :remember_created_at unless column_exists?(:users, :remember_created_at)
+ t.integer :sign_in_count, default: 0, null: false unless column_exists?(:users, :sign_in_count)
+ t.datetime :current_sign_in_at unless column_exists?(:users, :current_sign_in_at)
+ t.datetime :last_sign_in_at unless column_exists?(:users, :last_sign_in_at)
+ t.string :current_sign_in_ip unless column_exists?(:users, :current_sign_in_ip)
+ t.string :last_sign_in_ip unless column_exists?(:users, :last_sign_in_ip)
+ t.string :confirmation_token unless column_exists?(:users, :confirmation_token)
+ t.datetime :confirmed_at unless column_exists?(:users, :confirmed_at)
+ t.datetime :confirmation_sent_at unless column_exists?(:users, :confirmation_sent_at)
+ t.string :unconfirmed_email unless column_exists?(:users, :unconfirmed_email)
+ t.datetime :deleted_at unless column_exists?(:users, :deleted_at)
+ t.string :name, null: false, default: "" unless column_exists?(:users, :name)
+ t.boolean :private_scenarios, default: false unless column_exists?(:users, :private_scenarios)
+ t.boolean :admin, null: false, default: false unless column_exists?(:users, :admin)
+
+ # Add indexes only if they don't already exist
+ t.index :email, unique: true unless index_exists?(:users, :email, unique: true)
+ t.index :reset_password_token, unique: true unless index_exists?(:users, :reset_password_token, unique: true)
+ t.index :confirmation_token, unique: true unless index_exists?(:users, :confirmation_token, unique: true)
+ end
+ else
+ # If users table doesn't exist, create it
+ create_table :users, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.string :email, null: false, default: ""
+ t.string :encrypted_password, null: false, default: ""
+ t.string :reset_password_token
+ t.datetime :reset_password_sent_at
+ t.datetime :remember_created_at
+ t.integer :sign_in_count, default: 0, null: false
+ t.datetime :current_sign_in_at
+ t.datetime :last_sign_in_at
+ t.string :current_sign_in_ip
+ t.string :last_sign_in_ip
+ t.string :confirmation_token
+ t.datetime :confirmed_at
+ t.datetime :confirmation_sent_at
+ t.string :unconfirmed_email
+ t.datetime :deleted_at
+ t.string :name, null: false, default: ""
+ t.boolean :private_scenarios, default: false
+ t.boolean :admin, null: false, default: false
+
+ t.index :email, unique: true
+ t.index :reset_password_token, unique: true
+ t.index :confirmation_token, unique: true
+ end
+ end
+
+ # Add foreign keys for Doorkeeper
+ add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id unless foreign_key_exists?(:oauth_access_grants, :users, column: :resource_owner_id)
+ add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id unless foreign_key_exists?(:oauth_access_tokens, :users, column: :resource_owner_id)
+
+ change_table :oauth_applications, bulk: true do |t|
+ t.string :uri, null: false unless column_exists?(:oauth_applications, :uri)
+ t.integer :owner_id, null: false unless column_exists?(:oauth_applications, :owner_id)
+ t.string :owner_type, null: false unless column_exists?(:oauth_applications, :owner_type)
+
+ t.boolean :first_party, default: false, null: false unless column_exists?(:oauth_applications, :first_party)
+ end
+ add_index :oauth_applications, [:owner_id, :owner_type], name: "index_oauth_applications_on_owner_id_and_owner_type" unless index_exists?(:oauth_applications, [:owner_id, :owner_type])
+
+ # Create staff_applications table
+ create_table :staff_applications, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.string :name, null: false
+ t.bigint :user_id, null: false
+ t.bigint :application_id, null: false
+
+ t.index ["application_id"], name: "fk_rails_6768c0af4c"
+ t.index ["user_id", "name"], name: "index_staff_applications_on_user_id_and_name", unique: true
+ t.index ["user_id"], name: "index_staff_applications_on_user_id"
+ end
+
+ add_foreign_key :staff_applications, :oauth_applications, column: :application_id
+ add_foreign_key :staff_applications, :users
+
+ # Create oauth_openid_requests table
+ create_table :oauth_openid_requests, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.bigint :access_grant_id, null: false
+ t.string :nonce, null: false
+
+ t.index ["access_grant_id"], name: "index_oauth_openid_requests_on_access_grant_id"
+ end
+
+ add_foreign_key :oauth_openid_requests, :oauth_access_grants, column: :access_grant_id, on_delete: :cascade
+
+ # Create personal_access_tokens table
+ create_table :personal_access_tokens, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.bigint :user_id, null: false
+ t.bigint :oauth_access_token_id, null: false
+ t.string :name
+ t.datetime :last_used_at
+
+ t.index ["oauth_access_token_id"], name: "index_personal_access_tokens_on_oauth_access_token_id", unique: true
+ t.index ["user_id"], name: "index_personal_access_tokens_on_user_id"
+ end
+
+ add_foreign_key :personal_access_tokens, :users
+ add_foreign_key :personal_access_tokens, :oauth_access_tokens, column: :oauth_access_token_id
+
+ # Add foreign keys for oauth grants and tokens
+ add_foreign_key :oauth_access_grants, :oauth_applications, column: :application_id unless foreign_key_exists?(:oauth_access_grants, :oauth_applications, column: :application_id)
+ add_foreign_key :oauth_access_tokens, :oauth_applications, column: :application_id unless foreign_key_exists?(:oauth_access_tokens, :oauth_applications, column: :application_id)
+ end
+end
diff --git a/db/migrate/20241023123651_remove_null_constraint_from_application_id_in_oauth_access_tokens.rb b/db/migrate/20241023123651_remove_null_constraint_from_application_id_in_oauth_access_tokens.rb
new file mode 100644
index 0000000..ac9cf62
--- /dev/null
+++ b/db/migrate/20241023123651_remove_null_constraint_from_application_id_in_oauth_access_tokens.rb
@@ -0,0 +1,5 @@
+class RemoveNullConstraintFromApplicationIdInOAuthAccessTokens < ActiveRecord::Migration[7.2]
+ def change
+ change_column_null :oauth_access_tokens, :application_id, true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 90a08d4..1eda89d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_10_20_045257) do
+ActiveRecord::Schema[7.2].define(version: 2024_10_23_123651) do
create_table "featured_scenario_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id"
@@ -29,6 +29,68 @@
t.index ["saved_scenario_id"], name: "index_featured_scenarios_on_saved_scenario_id"
end
+ create_table "oauth_access_grants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "resource_owner_id", null: false
+ t.bigint "application_id", null: false
+ t.string "token", null: false
+ t.integer "expires_in", null: false
+ t.text "redirect_uri", null: false
+ t.string "scopes", default: "", null: false
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
+ t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
+ end
+
+ create_table "oauth_access_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "resource_owner_id"
+ t.bigint "application_id"
+ t.string "token", null: false
+ t.string "refresh_token"
+ t.integer "expires_in"
+ t.string "scopes"
+ t.datetime "created_at", null: false
+ t.datetime "revoked_at"
+ t.string "previous_refresh_token", default: "", null: false
+ t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
+ t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
+ t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
+ t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true
+ end
+
+ create_table "oauth_applications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "uid", null: false
+ t.string "secret", null: false
+ t.text "redirect_uri", null: false
+ t.string "scopes", default: "", null: false
+ t.boolean "confidential", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "first_party"
+ t.string "uri", null: false
+ t.integer "owner_id", null: false
+ t.string "owner_type", null: false
+ t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
+ t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
+ end
+
+ create_table "oauth_openid_requests", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.bigint "access_grant_id", null: false
+ t.string "nonce", null: false
+ t.index ["access_grant_id"], name: "index_oauth_openid_requests_on_access_grant_id"
+ end
+
+ create_table "personal_access_tokens", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.bigint "user_id", null: false
+ t.bigint "oauth_access_token_id", null: false
+ t.string "name"
+ t.datetime "last_used_at"
+ t.index ["oauth_access_token_id"], name: "index_personal_access_tokens_on_oauth_access_token_id", unique: true
+ t.index ["user_id"], name: "index_personal_access_tokens_on_user_id"
+ end
+
create_table "saved_scenario_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.integer "saved_scenario_id", null: false
t.integer "role_id", null: false
@@ -55,4 +117,54 @@
t.index ["discarded_at"], name: "index_saved_scenarios_on_discarded_at"
t.index ["scenario_id"], name: "index_saved_scenarios_on_scenario_id"
end
+
+ create_table "staff_applications", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.string "name", null: false
+ t.bigint "user_id", null: false
+ t.bigint "application_id", null: false
+ t.index ["application_id"], name: "fk_rails_6768c0af4c"
+ t.index ["user_id", "name"], name: "index_staff_applications_on_user_id_and_name", unique: true
+ t.index ["user_id"], name: "index_staff_applications_on_user_id"
+ end
+
+ create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.string "email", default: "", null: false
+ t.string "encrypted_password", default: "", null: false
+ t.string "reset_password_token"
+ t.datetime "reset_password_sent_at"
+ t.datetime "remember_created_at"
+ t.string "unconfirmed_email"
+ t.boolean "admin", default: false
+ t.string "name"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.string "legacy_password_salt"
+ t.datetime "deleted_at"
+ t.string "phone_number"
+ t.string "avatar_url"
+ t.text "bio"
+ t.integer "role"
+ t.string "confirmation_token"
+ t.datetime "confirmed_at"
+ t.datetime "confirmation_sent_at"
+ t.integer "sign_in_count", default: 0, null: false
+ t.datetime "current_sign_in_at"
+ t.datetime "last_sign_in_at"
+ t.string "current_sign_in_ip"
+ t.string "last_sign_in_ip"
+ t.boolean "private_scenarios", default: false
+ t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
+ t.index ["email"], name: "index_users_on_email", unique: true
+ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
+ end
+
+ add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
+ add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
+ add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
+ add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", on_delete: :cascade
+ add_foreign_key "personal_access_tokens", "oauth_access_tokens"
+ add_foreign_key "personal_access_tokens", "users"
+ add_foreign_key "staff_applications", "oauth_applications", column: "application_id"
+ add_foreign_key "staff_applications", "users"
end
diff --git a/lib/myetm/auth.rb b/lib/myetm/auth.rb
new file mode 100644
index 0000000..bbe576d
--- /dev/null
+++ b/lib/myetm/auth.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module MyEtm
+ # Contains useful methods for authentication.
+ module Auth
+ module_function
+
+ # 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?
+
+ 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.'
+ end
+
+ key = OpenSSL::PKey::RSA.new(2048).to_pem
+
+ unless ENV['DOCKER_BUILD']
+ key_path.write(key)
+ key_path.chmod(0o600)
+ end
+
+ key
+ end
+
+ # Returns the signing key as an OpenSSL::PKey::RSA instance.
+ def signing_key
+ OpenSSL::PKey::RSA.new(signing_key_content)
+ end
+
+ # Creates a new JWT for the given user, authorizing requests to the provided client.
+ def user_jwt(user, scopes: [])
+
+ payload = {
+ iss: Doorkeeper::OpenidConnect.configuration.issuer.call(user, nil),
+ aud: client_uri,
+ exp: 1.minute.from_now.to_i,
+ iat: Time.now.to_i,
+ scopes: scopes,
+ sub: user.id,
+ user: user.as_json(only: %i[id name])
+ }
+
+ key = signing_key
+ 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)
+ 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}")
+ end
+ end
+end
diff --git a/lib/myetm/mailchimp.rb b/lib/myetm/mailchimp.rb
new file mode 100644
index 0000000..99a75b7
--- /dev/null
+++ b/lib/myetm/mailchimp.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module MyEtm
+ # Mailchimp is a module which provides a client for the Mailchimp API.
+ module Mailchimp
+ module_function
+
+ def enabled?
+ Settings.dig(:mailchimp, :list_url).present? && Settings.dig(:mailchimp, :api_key).present?
+ end
+
+ def client
+ unless enabled?
+ raise "Mailchimp is not configured. Please set the 'mailchimp.list_url' and " \
+ "'mailchimp.api_key' settings."
+ end
+
+ Faraday.new(Settings.mailchimp.list_url) do |conn|
+ conn.request(:authorization, :basic, '', Settings.mailchimp.api_key)
+ conn.request(:json)
+ conn.response(:json)
+ conn.response(:raise_error)
+ end
+ end
+
+ def subscriber_id(email)
+ Digest::MD5.hexdigest(email.downcase)
+ end
+
+ # Fetches the subscriber information if it exists. Raises Faraday::ResourceNotFound if the
+ # subscriber
+ def fetch_subscriber(email)
+ client.get("members/#{subscriber_id(email)}").body
+ end
+
+ # Returns if the e-mail address is subscribed to the newsletter.
+ def subscribed?(email)
+ %w[pending subscribed].include?(fetch_subscriber(email)['status'])
+ rescue Faraday::ResourceNotFound
+ false
+ end
+ end
+end
diff --git a/lib/myetm/staff_applications.rb b/lib/myetm/staff_applications.rb
new file mode 100644
index 0000000..5fdfa8d
--- /dev/null
+++ b/lib/myetm/staff_applications.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module MyEtm
+ # Holds config information about OAuth accounts created for staff users.
+ module StaffApplications
+ class << self
+ def all
+ [etmodel, transition_paths]
+ end
+
+ def find(key)
+ case key.to_sym
+ when :etmodel then etmodel
+ when :transition_paths then transition_paths
+ else raise ArgumentError, "unknown application: #{key}"
+ end
+ end
+
+ private
+
+ # TODO fix these app configs - include Engine!
+
+ def etmodel
+ AppConfig.new(
+ key: 'etmodel',
+ name: 'ETModel (Local)',
+ scopes: 'openid email profile roles public scenarios:read scenarios:write scenarios:delete',
+ uri: 'http://localhost:3001',
+ redirect_path: '/auth/identity/callback',
+ run_command: 'bundle exec rails server -p %s',
+ config_path: 'config/settings.local.yml',
+ config_content: <<~YAML,
+ api_url: %s
+
+ identity:
+ client_id: %s
+ client_secret: %s
+ client_uri: %s
+ YAML
+ config_epilogue: <<~YAML
+ multi_year_charts_url:
+ YAML
+ )
+ end
+
+ def transition_paths
+ AppConfig.new(
+ key: 'transition_paths',
+ name: 'Transition Paths (Local)',
+ scopes: 'openid email profile public scenarios:read scenarios:write',
+ uri: 'http://localhost:3005',
+ redirect_path: '/api/auth/callback/identity',
+ run_command: 'yarn dev -p %s',
+ config_path: '.env.local',
+ config_content: <<~ENV,
+ # Protocol and host for ETEngine. No trailing slash please.
+ NEXT_PUBLIC_ETENGINE_URL=%s
+
+ # Protocol and host for ETModel. No trailing slash please.
+ NEXT_PUBLIC_ETMODEL_URL=%s
+
+ # Authentication.
+ NEXTAUTH_URL=%s
+ NEXTAUTH_SECRET=none
+ AUTH_CLIENT_ID=%s
+ AUTH_CLIENT_SECRET=%s
+
+ ENV
+ )
+ end
+ end
+ end
+end
diff --git a/lib/myetm/staff_applications/app_config.rb b/lib/myetm/staff_applications/app_config.rb
new file mode 100644
index 0000000..14cae40
--- /dev/null
+++ b/lib/myetm/staff_applications/app_config.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module MyEtm
+ module StaffApplications
+ # Defines an OAuth application which will be created for staff users.
+ class AppConfig < Dry::Struct
+ attribute :key, Dry::Types['strict.string']
+ attribute :name, Dry::Types['strict.string']
+ attribute :uri, Dry::Types['strict.string']
+ attribute? :redirect_path, Dry::Types['strict.string']
+ attribute :confidential, Dry::Types['strict.bool'].default(true)
+ attribute :scopes, Dry::Types['strict.string'].default('public')
+ attribute? :run_command, Dry::Types['strict.string']
+
+ attribute :config_path, Dry::Types['strict.string']
+ attribute? :config_prologue, Dry::Types['strict.string']
+ attribute :config_content, Dry::Types['strict.string']
+ attribute? :config_epilogue, Dry::Types['strict.string']
+
+ # Creates an attribute hash for the OAuth application.
+ def to_model_attributes
+ redirect_uri = URI.parse(uri)
+ redirect_uri.path = redirect_path
+
+ { name:, uri:, redirect_uri: redirect_uri.to_s, confidential:, scopes: }
+ end
+ end
+ end
+end
diff --git a/lib/tasks/identity.rake b/lib/tasks/identity.rake
new file mode 100644
index 0000000..2e8cb3a
--- /dev/null
+++ b/lib/tasks/identity.rake
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+namespace :identity do
+ task scheduled: %i[trim_tokens notify_expiring_tokens]
+
+ desc 'Clear our revoked and expired tokens'
+ task trim_tokens: :environment do
+ delete_before = (ENV['DOORKEEPER_DAYS_TRIM_THRESHOLD'] || 30).to_i.days.ago
+ expire = [
+ <<~SQL.squish, { delete_before: }
+ (revoked_at IS NOT NULL AND revoked_at < :delete_before) OR
+ (expires_in IS NOT NULL AND DATE_ADD(created_at, INTERVAL expires_in SECOND) < :delete_before)
+ SQL
+ ]
+
+ Doorkeeper::AccessGrant.where(expire).in_batches(&:delete_all)
+
+ Doorkeeper::AccessToken.where(expire).in_batches.each do |batch|
+ # Delete any personal access tokens that are associated with the access tokens.
+ PersonalAccessToken.where(oauth_access_token_id: batch.map(&:id)).in_batches(&:delete_all)
+
+ batch.delete_all
+ end
+ end
+
+ desc 'Notify users of soon-to-expire tokens'
+ task notify_expiring_tokens: :environment do
+ tokens = PersonalAccessToken
+ .joins(:oauth_access_token)
+ .where(<<~SQL.squish, { date: 3.days.from_now.beginning_of_day.utc })
+ oauth_access_tokens.expires_in IS NOT NULL AND
+ oauth_access_tokens.revoked_at IS NULL AND
+ (DATE_ADD(oauth_access_tokens.created_at, INTERVAL oauth_access_tokens.expires_in SECOND) > :date AND
+ DATE_ADD(oauth_access_tokens.created_at, INTERVAL oauth_access_tokens.expires_in SECOND) < DATE_ADD(:date, INTERVAL 1 DAY))
+ SQL
+
+ tokens.find_each do |token|
+ puts "Expiring: #{token.inspect}"
+ Identity::TokenMailer.expiring_token(token).deliver_later
+ end
+ end
+end
diff --git a/spec/components/identity/empty_state_component_spec.rb b/spec/components/identity/empty_state_component_spec.rb
new file mode 100644
index 0000000..83e31ee
--- /dev/null
+++ b/spec/components/identity/empty_state_component_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::EmptyStateComponent, type: :component do
+ context 'with title="No posts yet" and content="Create your first post"' do
+ let(:rendered) do
+ render_inline(
+ described_class.new(title: 'No posts yet')
+ .with_content('
Create your first post
'.html_safe)
+ )
+ end
+
+ it 'renders the title' do
+ expect(rendered).to have_css('h3', text: 'No posts yet')
+ end
+
+ it 'renders the content' do
+ expect(rendered).to have_css('p', text: 'Create your first post')
+ end
+
+ it 'renders no buttons wrapper' do
+ expect(rendered).not_to have_css("[data-testid='buttons']")
+ end
+ end
+
+ context 'with buttons' do
+ let(:rendered) do
+ render_inline(described_class.new(title: 'No posts yet')) do |component|
+ component.buttons { 'Buttons' }
+ end
+ end
+
+ it 'renders the buttons' do
+ expect(rendered).to have_css("[data-testid='buttons']")
+ end
+ end
+end
diff --git a/spec/components/identity/profile_email_component_spec.rb b/spec/components/identity/profile_email_component_spec.rb
new file mode 100644
index 0000000..dde32d9
--- /dev/null
+++ b/spec/components/identity/profile_email_component_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::ProfileEmailComponent, type: :component do
+ context 'with a confirmed e-mail address' do
+ let(:rendered) do
+ render_inline(described_class.new(
+ title: 'E-mail', email: 'hello@example.org', confirmed: true
+ ))
+ end
+
+ it 'renders the e-mail' do
+ expect(rendered).to have_text('hello@example.org')
+ end
+
+ it 'shows that the e-mail has been confirmed' do
+ expect(rendered).to have_css('span', text: 'Verified')
+ end
+ end
+
+ context 'with an unconfirmed e-mail address' do
+ let(:rendered) do
+ render_inline(described_class.new(
+ title: 'E-mail', email: 'hello@example.org', confirmed: false
+ ))
+ end
+
+ it 'renders the e-mail' do
+ expect(rendered).to have_text('hello@example.org')
+ end
+
+ it 'shows that the e-mail has not been confirmed' do
+ expect(rendered).to have_css('span', text: 'Not verified')
+ end
+
+ it 'renders a link to resend confirmation instructions' do
+ expect(rendered).to have_button(text: 'Resend confirmation instructions')
+ end
+ end
+end
diff --git a/spec/components/identity/sidebar_item_component_spec.rb b/spec/components/identity/sidebar_item_component_spec.rb
new file mode 100644
index 0000000..2e7b19d
--- /dev/null
+++ b/spec/components/identity/sidebar_item_component_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::SidebarItemComponent, type: :component do
+ context 'with an inactive item' do
+ let(:rendered) do
+ render_inline(described_class.new(
+ path: '/', title: 'Hello', explanation: 'Hello Person', active: false
+ ))
+ end
+
+ it 'renders the title and explanation' do
+ expect(rendered).to have_text('Hello')
+ expect(rendered).to have_text('Hello Person')
+ end
+
+ it 'has active item classes' do
+ expect(rendered).to have_css('a', class: 'border-gray-200')
+ end
+ end
+
+ context 'with an active item' do
+ let(:rendered) do
+ render_inline(described_class.new(
+ path: '/', title: 'Hello', explanation: 'Hello Person', active: true
+ ))
+ end
+
+ it 'renders the title and explanation' do
+ expect(rendered).to have_text('Hello')
+ expect(rendered).to have_text('Hello Person')
+ end
+
+ it 'has active item classes' do
+ expect(rendered).to have_css('a', class: 'border-midnight-600')
+ end
+ end
+end
diff --git a/spec/components/identity/token/scope_component_spec.rb b/spec/components/identity/token/scope_component_spec.rb
new file mode 100644
index 0000000..bb0c1e6
--- /dev/null
+++ b/spec/components/identity/token/scope_component_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::Token::ScopeComponent, type: :component do
+ context 'when the scope is enabled' do
+ let(:rendered) do
+ render_inline(described_class.new(name: 'My scope', enabled: true))
+ end
+
+ it 'renders the scope name' do
+ expect(rendered).to have_content('My scope')
+ end
+
+ it 'does not hide the scope from screen readers' do
+ expect(rendered).not_to have_css('[aria-hidden="true"]')
+ end
+ end
+
+ context 'when the scope is disabled' do
+ let(:rendered) do
+ render_inline(described_class.new(name: 'My scope', enabled: false))
+ end
+
+ it 'renders the scope name' do
+ expect(rendered).to have_content('My scope')
+ end
+
+ it 'hides the scope from screen readers' do
+ expect(rendered).to have_css('[aria-hidden="true"]')
+ end
+ end
+end
diff --git a/spec/components/identity/token_component_spec.rb b/spec/components/identity/token_component_spec.rb
new file mode 100644
index 0000000..43c3802
--- /dev/null
+++ b/spec/components/identity/token_component_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::TokenComponent, type: :component do
+ def build_token(expires_in: nil, created_at: Time.now, scopes: '')
+ PersonalAccessToken.new(
+ id: 1,
+ name: 'API access',
+ oauth_access_token: Doorkeeper::AccessToken.new(
+ token: 'etm_1234567890',
+ expires_in:,
+ scopes:,
+ created_at:
+ )
+ )
+ end
+
+ let(:rendered) do
+ render_inline(described_class.new(token:))
+ end
+
+ context 'with a non-expiring token' do
+ let(:token) do
+ build_token(expires_in: nil)
+ end
+
+ it 'renders that the token never expires' do
+ expect(rendered.css('[data-testid="expires"]')).to have_content('Never')
+ end
+ end
+
+ context 'with a token that expires in one year' do
+ let(:token) do
+ build_token(expires_in: 1.year)
+ end
+
+ it 'renders when the token expires' do
+ expect(rendered.css('[data-testid="expires"]')).to have_content('1 year from now')
+ end
+ end
+
+ context 'when the token was just created' do
+ let(:token) do
+ build_token(created_at: Time.zone.now)
+ end
+
+ it 'renders the full token' do
+ expect(rendered).to have_css('input[value="etm_1234567890"]')
+ end
+
+ it 'renders the clipboard button' do
+ expect(rendered).to have_button('Copy token to clipboard')
+ end
+ end
+
+ context 'when the token is two minutes old' do
+ let(:token) do
+ build_token(created_at: 2.minutes.ago)
+ end
+
+ it 'does not render the full token' do
+ expect(rendered).not_to have_css('input[value="etm_1234567890"]')
+ expect(rendered).to have_css('.font-mono', text: 'etm_12345...')
+ end
+
+ it 'does not render the clipboard button' do
+ expect(rendered).not_to have_button('Copy token to clipboard')
+ end
+ end
+
+ context 'with scopes="public scenarios:read"' do
+ let(:token) do
+ build_token(scopes: 'public scenarios:read')
+ end
+
+ it 'shows that the token may read public scenarios' do
+ expect(rendered).to have_css(
+ '[data-testid="scope:public"]:not([aria-hidden="true"])',
+ text: 'Read public scenarios'
+ )
+ end
+
+ it 'shows that the token may read private scenarios' do
+ expect(rendered).to have_css(
+ '[data-testid="scope:scenarios:read"]:not([aria-hidden="true"])',
+ text: 'Read your private scenarios'
+ )
+ end
+
+ it 'shows that the token may not write scenarios' do
+ expect(rendered).to have_css(
+ '[data-testid="scope:scenarios:write"][aria-hidden="true"]',
+ text: 'Create new scenarios and change your public and private scenarios'
+ )
+ end
+
+ it 'shows that the token may not delete scenarios' do
+ expect(rendered).to have_css(
+ '[data-testid="scope:scenarios:delete"][aria-hidden="true"]',
+ text: 'Delete your public and private scenarios'
+ )
+ end
+ end
+end
diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb
new file mode 100644
index 0000000..a753553
--- /dev/null
+++ b/spec/controllers/users/sessions_controller_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+RSpec.describe Users::SessionsController do
+ let(:user) { create(:user) }
+
+ let(:application) do
+ OAuthApplication.create!(
+ name: 'Test Application',
+ uri: 'https://example.com',
+ redirect_uri: 'https://example.com/auth/callback',
+ owner: user
+ )
+ end
+
+ let(:token) do
+ Doorkeeper::AccessToken.create!(
+ application:,
+ resource_owner_id: user.id
+ )
+ end
+
+ before do
+ request.env['devise.mapping'] = Devise.mappings[:user]
+ end
+
+ context 'when signing out with an access token' do
+ before { sign_in(user) }
+
+ it 'redirects to the application URL' do
+ delete :destroy, params: { access_token: token.token }
+ expect(response).to redirect_to('https://example.com')
+ end
+
+ it 'revokes the token' do
+ expect { delete(:destroy, params: { access_token: token.token }) }
+ .to change { token.reload.revoked? }.from(false).to(true)
+ end
+ end
+
+ context 'when signing out with no access token' do
+ before { sign_in(user) }
+
+ it 'redirects to ETModel' do
+ delete :destroy
+ expect(response).to redirect_to(Settings.etmodel_uri)
+ end
+ end
+
+ context 'when signing out with an access token that does not exist' do
+ before { sign_in(user) }
+
+ it 'redirects to ETModel' do
+ delete :destroy, params: { access_token: 'invalid' }
+ expect(response).to redirect_to(Settings.etmodel_uri)
+ end
+ end
+
+ context 'when signing out with an access token that belongs to someone else' do
+ before do
+ token.update!(resource_owner_id: create(:user).id)
+ sign_in(user)
+ end
+
+ it 'redirects to ETModel' do
+ delete :destroy, params: { access_token: 'invalid' }
+ expect(response).to redirect_to(Settings.etmodel_uri)
+ end
+
+ it 'does not revoke the token' do
+ expect { delete(:destroy, params: { access_token: token.token }) }
+ .not_to change { token.reload.revoked? }.from(false)
+ end
+ end
+end
diff --git a/spec/factories/access_token.rb b/spec/factories/access_token.rb
new file mode 100644
index 0000000..9b75f69
--- /dev/null
+++ b/spec/factories/access_token.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :access_token, class: 'Doorkeeper::AccessToken' do
+ scopes { 'public' }
+ sequence(:resource_owner_id) { |n| n }
+ end
+
+ factory :access_token_read, parent: :access_token do
+ scopes { 'public scenarios:read' }
+ end
+
+ factory :access_token_write, parent: :access_token do
+ scopes { 'public scenarios:read scenarios:write' }
+ end
+
+ factory :access_token_delete, parent: :access_token do
+ scopes { 'public scenarios:read scenarios:delete' }
+ end
+end
diff --git a/spec/factories/personal_access_token.rb b/spec/factories/personal_access_token.rb
new file mode 100644
index 0000000..b068102
--- /dev/null
+++ b/spec/factories/personal_access_token.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :personal_access_token do
+ name { 'My Personal Access Token' }
+ oauth_access_token { create(:access_token_read, resource_owner_id: user.id, application_id: 1) }
+ user { create(:user) }
+ end
+end
diff --git a/spec/factories/role.rb b/spec/factories/role.rb
new file mode 100644
index 0000000..21dbc4a
--- /dev/null
+++ b/spec/factories/role.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :role do
+ name { 'user' }
+ end
+
+ factory :admin_role, parent: :role do
+ name { 'admin' }
+ end
+end
diff --git a/spec/factories/scenario.rb b/spec/factories/scenario.rb
new file mode 100644
index 0000000..763121f
--- /dev/null
+++ b/spec/factories/scenario.rb
@@ -0,0 +1,65 @@
+FactoryBot.define do
+ factory :scenario do
+ transient do
+ user { nil }
+ end
+
+ area_code { "nl" }
+ end_year { 2040 }
+
+ after(:build) do |scenario, evaluator|
+ if evaluator.user.present?
+ scenario.scenario_users << build(
+ :scenario_user,
+ user: evaluator.user,
+ scenario: scenario
+ )
+ end
+ end
+ end
+
+ factory :scenario_with_user_values, parent: :scenario do
+ user_values do
+ {
+ foo_demand: 10.0,
+ input_2: 20.0,
+ input_3: 30.0
+ }
+ end
+ end
+
+ factory :scenario_visible_in_homepage, parent: :scenario do
+ in_start_menu { true }
+ end
+
+ factory :scenario_attachment do
+ key { 'interconnector_1_price_curve' }
+ scenario
+ end
+
+ factory :scaled_scenario, parent: :scenario_with_user_values do
+ scaler do
+ ScenarioScaling.new(
+ area_attribute: 'present_number_of_residences',
+ base_value: 8_000_000.0,
+ has_agriculture: true,
+ has_energy: true,
+ has_industry: false,
+ value: 100.0
+ )
+ end
+ end
+
+ factory :heat_network_order do
+ scenario
+ order { HeatNetworkOrder.default_order.reverse }
+ end
+
+ factory :scenario_with_heat_network, parent: :scenario_with_user_values do
+ after(:create) do |scenario, evaluator|
+ create_list(:heat_network_order, 1, scenario: scenario)
+
+ scenario.reload
+ end
+ end
+end
diff --git a/spec/factories/staff_application.rb b/spec/factories/staff_application.rb
new file mode 100644
index 0000000..660d3a5
--- /dev/null
+++ b/spec/factories/staff_application.rb
@@ -0,0 +1,25 @@
+FactoryBot.define do
+ factory :staff_application do
+ transient do
+ name { 'etmodel' }
+ end
+
+ user
+ application { create(:oauth_application, owner: user) }
+
+ after(:build) do |staff_application, evaluator|
+ staff_application.name = evaluator.name
+ end
+ end
+
+ factory :oauth_application, class: 'Doorkeeper::Application' do
+ name { 'Default App' }
+ uid { SecureRandom.hex(8) }
+ secret { SecureRandom.hex(16) }
+ redirect_uri { 'https://example.com/callback' }
+ scopes { '' }
+ confidential { true }
+ uri { 'https://example.com' }
+ owner { association(:user) }
+ end
+end
diff --git a/spec/factories/user.rb b/spec/factories/user.rb
new file mode 100644
index 0000000..d78cee1
--- /dev/null
+++ b/spec/factories/user.rb
@@ -0,0 +1,15 @@
+FactoryBot.define do
+ factory :user do
+ name { 'John Doe' }
+ sequence(:email) { |n| "hello.#{n}@quintel.com" }
+ password { 'password' }
+
+ trait :confirmed_at do
+ confirmed_at { Time.current }
+ end
+ end
+
+ factory :admin, parent: :user do
+ admin { true }
+ end
+end
diff --git a/spec/jobs/identity/destroy_user_job_spec.rb b/spec/jobs/identity/destroy_user_job_spec.rb
new file mode 100644
index 0000000..5bd8ce8
--- /dev/null
+++ b/spec/jobs/identity/destroy_user_job_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::DestroyUserJob, type: :job do
+ context 'when Settings.etmodel_uri is set' do
+ let(:user) { create(:user) }
+ let(:connection) { instance_double(Faraday::Connection) }
+
+ before do
+ Settings.etmodel_uri = 'http://example.org'
+
+ allow(MyEtm::Auth)
+ .to receive(:client_app_client)
+ .with(user)
+ .and_return(connection)
+
+ allow(connection).to receive(:delete)
+ end
+
+ after do
+ Settings.reload!
+ end
+
+ pending 'sends a PUT request to the ETModel API' do
+ described_class.perform_now(user.id)
+ expect(connection).to have_received(:delete).with('/api/v1/user')
+ end
+
+ pending 'destroys the user' do
+ expect { described_class.perform_now(user.id) }
+ .to change { User.where(id: user.id).count }.by(-1)
+ end
+
+ pending 'returns true' do
+ expect(described_class.perform_now(user.id)).to be(true)
+ end
+ end
+end
diff --git a/spec/jobs/identity/sync_user_job_spec.rb b/spec/jobs/identity/sync_user_job_spec.rb
new file mode 100644
index 0000000..2465008
--- /dev/null
+++ b/spec/jobs/identity/sync_user_job_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::SyncUserJob, type: :job do
+ context 'when Settings.etmodel_uri is set' do
+ let(:user) { create(:user) }
+ let(:connection) { instance_double(Faraday::Connection) }
+
+ before do
+ Settings.etmodel_uri = 'http://example.org'
+
+ allow(MyEtm::Auth)
+ .to receive(:client_app_client)
+ .with(user)
+ .and_return(connection)
+
+ allow(connection).to receive(:put)
+ end
+
+ after do
+ Settings.reload!
+ end
+
+ it 'sends a PUT request to the ETModel API' do
+ described_class.perform_now(user.id)
+ expect(connection).to have_received(:put).with('/api/v1/user', anything)
+ end
+
+ it 'returns true' do
+ expect(described_class.perform_now(user.id)).to be(true)
+ end
+ end
+end
diff --git a/spec/jobs/track_personal_access_token_use.rb b/spec/jobs/track_personal_access_token_use.rb
new file mode 100644
index 0000000..ba48819
--- /dev/null
+++ b/spec/jobs/track_personal_access_token_use.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe TrackPersonalAccessTokenUse, type: :job do
+ context 'when Settings.etmodel_uri is set' do
+ let(:user) { create(:user) }
+ let(:access_token) { create(:access_token, resource_owner_id: user.id) }
+
+ let(:personal_token) do
+ PersonalAccessToken.create!(oauth_access_token: access_token, name: 'Test', user:)
+ end
+
+ context 'when the token is a personal access token' do
+ before do
+ personal_token.update!(last_used_at: 1.day.ago)
+ end
+
+ it 'updates the last_used_at attribute' do
+ was = personal_token.last_used_at
+ now = Time.now.utc
+
+ expect { described_class.perform_now(access_token.id, now) }
+ .to change { personal_token.reload.last_used_at }.from(was).to(now)
+ end
+ end
+
+ context 'when the token has never been used' do
+ before do
+ personal_token.update!(last_used_at: nil)
+ end
+
+ it 'updates the last_used_at attribute' do
+ now = Time.now.utc
+
+ expect { described_class.perform_now(access_token.id, now) }
+ .to change { personal_token.reload.last_used_at }.from(nil).to(now)
+ end
+ end
+
+ context 'when the token is not a personal access token' do
+ before do
+ personal_token.destroy!
+ end
+
+ it 'does not raise an error' do
+ expect { described_class.perform_now(access_token.id, Time.now.utc) }.not_to raise_error
+ end
+ end
+
+ context 'when the token last use is more recent than the given time' do
+ before do
+ personal_token.update!(last_used_at: was)
+ end
+
+ let(:was) { 1.hour.ago }
+
+ it 'does not update the last_used_at attribute' do
+ expect { described_class.perform_now(access_token.id, 1.day.ago) }
+ .not_to change { personal_token.reload.last_used_at }.from(was)
+ end
+ end
+ end
+end
diff --git a/spec/mailers/identity/token_spec.rb b/spec/mailers/identity/token_spec.rb
new file mode 100644
index 0000000..ed66623
--- /dev/null
+++ b/spec/mailers/identity/token_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe Identity::TokenMailer, type: :mailer do
+ describe 'created_token' do
+ let(:user) { create(:user) }
+
+ let(:token) do
+ CreatePersonalAccessToken.call(user:, params: { name: 'test' }).value!
+ end
+
+ let(:mail) { described_class.created_token(token) }
+
+ it 'renders the headers' do
+ expect(mail.subject).to eq('You created a new token')
+ expect(mail.to).to eq([user.email])
+ expect(mail.from).to eq(Mail::Field.parse("From: #{Settings.mailer.from}").addresses)
+ end
+
+ it 'renders the body' do
+ expect(mail.body.encoded).to match('You just created a new personal access token')
+ expect(mail.body.encoded).to match('- View your public scenarios')
+ expect(mail.body.encoded).to match("- View other people's public scenarios")
+ expect(mail.body.encoded).not_to match('- View your private scenarios')
+ end
+ end
+
+ describe 'expiring_token' do
+ let(:user) { create(:user) }
+
+ let(:token) do
+ CreatePersonalAccessToken.call(user:, params: { name: 'test' }).value!
+ end
+
+ let(:mail) { described_class.expiring_token(token) }
+
+ it 'renders the headers' do
+ expect(mail.subject).to eq('Your personal access token will expire soon')
+ expect(mail.to).to eq([user.email])
+ expect(mail.from).to eq(Mail::Field.parse("From: #{Settings.mailer.from}").addresses)
+ end
+
+ it 'renders the body' do
+ expect(mail.body.encoded).to match('You have an access token which will expire soon')
+ expect(mail.body.encoded).to match('- View your public scenarios')
+ expect(mail.body.encoded).to match("- View other people's public scenarios")
+ expect(mail.body.encoded).not_to match('- View your private scenarios')
+ end
+ end
+end
diff --git a/spec/mailers/previews/devise_confirmation_mailer_preview.rb b/spec/mailers/previews/devise_confirmation_mailer_preview.rb
new file mode 100644
index 0000000..68accf9
--- /dev/null
+++ b/spec/mailers/previews/devise_confirmation_mailer_preview.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class DeviseConfirmationMailerPreview < ActionMailer::Preview
+ def confirmation_instructions
+ user = User.first || User.new(email: 'test@example.com', confirmation_token: 'fake_token')
+ Devise::Mailer.confirmation_instructions(user, user.confirmation_token)
+ end
+end
diff --git a/spec/mailers/previews/identity/token_preview.rb b/spec/mailers/previews/identity/token_preview.rb
new file mode 100644
index 0000000..7c8c3ad
--- /dev/null
+++ b/spec/mailers/previews/identity/token_preview.rb
@@ -0,0 +1,9 @@
+# Preview all emails at http://localhost:3000/rails/mailers/identity/token
+class Identity::TokenPreview < ActionMailer::Preview
+
+ # Preview this email at http://localhost:3000/rails/mailers/identity/token/created_token
+ def created_token
+ Identity::TokenMailer.created_token
+ end
+
+end
diff --git a/spec/models/saved_scenario_spec.rb b/spec/models/saved_scenario_spec.rb
index 16c1f9d..eed7ef2 100644
--- a/spec/models/saved_scenario_spec.rb
+++ b/spec/models/saved_scenario_spec.rb
@@ -7,7 +7,7 @@
end
describe "#scenario" do
- pending "returns nil if scenario is not found in ETEngine" do
+ pending "returns nil if scenario is not found in MyEtm" do
allow(FetchAPIScenario).to receive(:call)
.with(anything, 0).and_return(ServiceResult.failure('Scenario not found'))
diff --git a/spec/models/staff_application_spec.rb b/spec/models/staff_application_spec.rb
new file mode 100644
index 0000000..7502c43
--- /dev/null
+++ b/spec/models/staff_application_spec.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe StaffApplication, type: :model do
+ it { is_expected.to belong_to(:user) }
+ it { is_expected.to belong_to(:application).dependent(:destroy) }
+end
diff --git a/spec/myetm/auth_spec.rb b/spec/myetm/auth_spec.rb
new file mode 100644
index 0000000..47caaa8
--- /dev/null
+++ b/spec/myetm/auth_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+RSpec.describe MyEtm::Auth do
+ describe '.user_jwt' do
+ subject do
+ JWT.decode(
+ described_class.user_jwt(user, scopes: %w[read write]),
+ described_class.signing_key.public_key,
+ true,
+ algorithm: 'RS256'
+ )
+ end
+
+ before { Settings.etmodel_uri = 'http://etmodel.test' }
+
+ after { Settings.reload! }
+
+ let(:user) { create(:user) }
+
+ let(:payload) { subject[0] }
+ let(:header) { subject[1] }
+
+ pending 'returns a JWT for the given user' do
+ expect(payload['user']).to eq(user.as_json(only: %i[id name]))
+ end
+
+ pending 'includes the scopes in the JWT payload' do
+ expect(payload['scopes']).to eq(%w[read write])
+ end
+
+ pending 'includes the issuer in the JWT payload' do
+ expect(payload['iss']).to eq(Doorkeeper::OpenidConnect.configuration.issuer.call(user, nil))
+ end
+
+ pending 'includes the audience in the JWT payload' do
+ expect(payload['aud']).to eq(Settings.etmodel_uri)
+ end
+
+ pending 'includes the expiration time in the JWT payload' do
+ expect(payload['exp']).to be_within(1).of(1.minute.from_now.to_i)
+ end
+
+ pending 'includes the issued at time in the JWT payload' do
+ expect(payload['iat']).to be_within(1).of(Time.now.to_i)
+ end
+
+ pending 'includes the subject in the JWT payload' do
+ expect(payload['sub']).to eq(user.id)
+ end
+
+ pending 'includes the key ID in the JWT header' do
+ expect(header['kid']).to eq(described_class.signing_key.to_jwk['kid'])
+ end
+
+ pending 'raises an error when no ETModel URI is set' do
+ Settings.etmodel_uri = nil
+
+ expect { described_class.user_jwt(build(:user)) }.to raise_error(
+ "No ETModel URI. Please set the 'etmodel_uri' setting in config/settings.local.yml."
+ )
+ end
+ end
+
+ describe '.client_app_client' do
+ subject do
+ described_class.client_app_client(user, etmodel)
+ end
+
+ before { Settings.etmodel_uri = 'http://etmodel.test' }
+
+ after { Settings.reload! }
+
+ let(:user) { create(:user) }
+
+ pending 'sets the scheme for the client' do
+ expect(subject.scheme).to eq('http')
+ end
+
+ pending 'sets the host for the client' do
+ expect(subject.host).to eq('etmodel.test')
+ end
+ end
+end
diff --git a/spec/services/create_personal_access_token_spec.rb b/spec/services/create_personal_access_token_spec.rb
new file mode 100644
index 0000000..e50cd7e
--- /dev/null
+++ b/spec/services/create_personal_access_token_spec.rb
@@ -0,0 +1,275 @@
+# frozen_string_literal: true
+
+RSpec.describe CreatePersonalAccessToken do
+ let(:user) { create(:user) }
+
+ shared_examples_for 'creating a personal access token' do
+ it 'is successful' do
+ expect(action).to be_success
+ end
+
+ it 'creating a personal access token' do
+ expect { action }.to change(user.personal_access_tokens, :count).by(1)
+ end
+
+ it 'creates an oauth access token' do
+ expect { action }.to change(user.access_tokens, :count).by(1)
+ end
+
+ it 'sets the personal token name' do
+ expect(action.value!.name).to eq('API access')
+ end
+
+ it 'associates the oauth token with the personal token' do
+ expect(action.value!.oauth_access_token).to eq(user.access_tokens.last)
+ end
+ end
+
+ shared_examples_for 'failing to create a personal access token' do
+ it 'returns a failure' do
+ expect(action).to be_failure
+ end
+
+ it 'creating a personal access token' do
+ expect { action }.not_to change(user.personal_access_tokens, :count)
+ end
+
+ it 'creates an oauth access token' do
+ expect { action }.not_to change(user.access_tokens, :count)
+ end
+ end
+
+ # ------------------------------------------------------------------------------------------------
+
+ context 'with an expiry date in the past' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', expires_in: -365 })
+ end
+
+ include_examples 'failing to create a personal access token'
+
+ it 'sets the expiry date to 7 days from now' do
+ expect(action.failure.errors[:expires_in]).to include('must be greater than 0')
+ end
+ end
+
+ context 'with an expiry date 7 days from now' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', expires_in: 7 })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets the expiry date to 7 days from now' do
+ expect(action.value!.oauth_access_token.expires_in).to eq(7.days.to_i)
+ end
+ end
+
+ context 'with an expiry date a year from now' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', expires_in: 365 })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets the expiry date to one from now' do
+ expect(action.value!.oauth_access_token.expires_in).to eq(365.days.to_i)
+ end
+ end
+
+ context 'with no expiry date' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', expires_in: 'never' })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets no expiry date' do
+ expect(action.value!.oauth_access_token.expires_in).to be_nil
+ end
+ end
+
+ context 'with scopes that are not valid' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', permissions: 'invalid' })
+ end
+
+ include_examples 'failing to create a personal access token'
+
+ it 'sets the public scope' do
+ expect(action.failure.errors[:permissions]).to include('is not included in the list')
+ end
+ end
+
+ context 'with permissions="write"' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', permissions: 'write' })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets scopes to "openid public scenarios:read scenarios:write"' do
+ expect(action.value!.oauth_access_token.scopes.to_a)
+ .to eq(%w[openid public scenarios:read scenarios:write])
+ end
+ end
+
+ context 'with email_scope="1"' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', email_scope: '1' })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets scopes to "openid public email' do
+ expect(action.value!.oauth_access_token.scopes.to_a) .to eq(%w[openid public email])
+ end
+ end
+
+ context 'with profile_scope="1"' do
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access', profile_scope: '1' })
+ end
+
+ include_examples 'creating a personal access token'
+
+ it 'sets scopes to "openid public profile' do
+ expect(action.value!.oauth_access_token.scopes.to_a) .to eq(%w[openid public profile])
+ end
+ end
+
+ context 'when there is a single collision with the token' do
+ before do
+ allow(Doorkeeper::OAuth::Helpers::UniqueToken).to receive(:generate)
+ .and_invoke(->(*) { 'token_abc' }, ->(*) { 'token_abc' }, ->(*) { 'token_123' })
+
+ user.access_tokens.create!({})
+ end
+
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access' })
+ end
+
+ include_examples 'creating a personal access token'
+ end
+
+ context 'when there is a repeated collision with the token' do
+ before do
+ allow(Doorkeeper::OAuth::Helpers::UniqueToken).to receive(:generate).and_return('token_abc')
+
+ user.access_tokens.create!({})
+ end
+
+ let(:action) do
+ described_class.call(user:, params: { name: 'API access' })
+ end
+
+ it 'raises an error' do
+ expect { action }.to raise_error(ActiveRecord::RecordInvalid, /Token has already been taken/)
+ end
+ end
+
+ # ------------------------------------------------------------------------------------------------
+
+ describe 'Params' do
+ let(:attributes) do
+ { name: 'My token' }
+ end
+
+ let(:params) do
+ described_class::Params.new(attributes)
+ end
+
+ before do
+ params.valid?
+ end
+
+ context 'when given no name' do
+ let(:attributes) { super().merge(name: '') }
+
+ it 'has an error on name' do
+ expect(params.errors[:name]).to include("can't be blank")
+ end
+ end
+
+ context 'when given expires_in=7' do
+ let(:attributes) do
+ super().merge(expires_in: 7)
+ end
+
+ it 'has no error on expires_in' do
+ expect(params.errors[:expires_in]).to be_empty
+ end
+
+ it 'coerces the value to 7' do
+ expect(params.expires_in).to eq(7)
+ end
+
+ it 'sets OAuth expires_at to 7 days from now' do
+ expect(params.to_oauth_token_params[:expires_in]).to eq(7.days)
+ end
+ end
+
+ context 'when given expires_in="7"' do
+ let(:attributes) do
+ super().merge(expires_in: '7')
+ end
+
+ it 'has no error on expires_in' do
+ expect(params.errors[:expires_in]).to be_empty
+ end
+
+ it 'coerces the value to 7' do
+ expect(params.expires_in).to eq(7)
+ end
+ end
+
+ context 'when given expires_in=nil' do
+ let(:attributes) do
+ super().merge(expires_in: nil)
+ end
+
+ it 'has has an error on expires_in' do
+ expect(params.errors[:expires_in]).to include('is not a number')
+ end
+ end
+
+ context 'when given expires_in="never"' do
+ let(:attributes) do
+ super().merge(expires_in: 'never')
+ end
+
+ it 'has no error on expires_in' do
+ expect(params.errors[:expires_in]).to be_empty
+ end
+
+ it 'coerces the value to nil' do
+ expect(params.expires_in).to eq('never')
+ end
+
+ it 'sets OAuth expires_at to nil' do
+ expect(params.to_oauth_token_params[:expires_in]).to eq(nil)
+ end
+ end
+
+ context 'when given expires_in="invalid"' do
+ let(:attributes) do
+ super().merge(expires_in: 'invalid')
+ end
+
+ it 'has an error on expires_in' do
+ expect(params.errors[:expires_in]).to include('is not a number')
+ end
+ end
+
+ context 'when given expires_in=-1' do
+ let(:attributes) do
+ super().merge(expires_in: -1)
+ end
+
+ it 'has an error on expires_in' do
+ expect(params.errors[:expires_in]).to include('must be greater than 0')
+ end
+ end
+ end
+end
diff --git a/spec/services/create_staff_application_spec.rb b/spec/services/create_staff_application_spec.rb
new file mode 100644
index 0000000..13c2f1e
--- /dev/null
+++ b/spec/services/create_staff_application_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+RSpec.describe CreateStaffApplication do
+ let(:user) { create(:admin) }
+
+ let(:app_config) do
+ MyEtm::StaffApplications::AppConfig.new(
+ key: 'my_app',
+ name: 'My application',
+ uri: 'http://localhost:3002',
+ scopes: 'email',
+ redirect_path: '/auth',
+ config_path: 'conf.yml',
+ config_content: ''
+ )
+ end
+
+ let(:app) { described_class.call(user, app_config).value! }
+
+ pending 'when the user does not have a matching application' do
+ it 'creates a new application' do
+ expect { app }
+ .to change { user.staff_applications.count }
+ .from(0)
+ .to(1)
+ end
+
+ it 'creates OAuth applications for the user' do
+ expect { app }
+ .to change { user.oauth_applications.count }
+ .from(0)
+ .to(1)
+ end
+
+ it 'sets the default URI' do
+ expect(app.application.uri).to eq('http://localhost:3002')
+ end
+
+ it 'sets the default redirect URI' do
+ expect(app.application.redirect_uri).to eq('http://localhost:3002/auth')
+ end
+
+ pending 'when given a custom URI' do
+ let(:app) { described_class.call(user, app_config, uri: 'http://myapp.test').value! }
+
+ it 'sets a custom URI' do
+ expect(app.application.uri).to eq('http://myapp.test')
+ end
+
+ it 'sets a custom redirect URI' do
+ expect(app.application.redirect_uri).to eq('http://myapp.test/auth')
+ end
+ end
+ end
+
+ pending 'when the user already has the staff application' do
+ before do
+ described_class.call(user, app_config)
+ end
+
+ it 'does not create a new application' do
+ expect { app }.not_to change(user.staff_applications, :count)
+ end
+
+ pending 'when the application uri is different' do
+ it 'does not update the application URI' do
+ new_config = MyEtm::StaffApplications::AppConfig.new(
+ app_config.to_h.merge(url: 'http://wwww.example.org')
+ )
+
+ oauth_app = user.staff_applications.find_by!(name: app_config.key).application
+ oauth_app.update!(uri: 'http://other-host:3001')
+
+ expect { described_class.call(user, new_config) }
+ .not_to change { oauth_app.reload.uri }
+ .from('http://other-host:3001')
+ end
+ end
+
+ pending 'when the application redirect_uri is different' do
+ it 'updates the path of the existing URI' do
+ new_config = MyEtm::StaffApplications::AppConfig.new(
+ app_config.to_h.merge(redirect_path: '/auth/callback')
+ )
+
+ oauth_app = user.staff_applications.find_by!(name: app_config.key).application
+
+ oauth_app.update!(
+ uri: 'http://other-host:3001',
+ redirect_uri: 'http://other-host:3001/auth'
+ )
+
+ expect { described_class.call(user, new_config) }
+ .to change { oauth_app.reload.redirect_uri }
+ .from('http://other-host:3001/auth')
+ .to('http://other-host:3001/auth/callback')
+ end
+ end
+ pending 'when the application scope is different' do
+ it 'updates the scopes of the existing application' do
+ new_config = MyEtm::StaffApplications::AppConfig.new(
+ app_config.to_h.merge(scopes: 'profile')
+ )
+
+ oauth_app = user.staff_applications.find_by!(name: app_config.key).application
+ oauth_app.update!(scopes: 'openid')
+
+ expect { described_class.call(user, new_config) }
+ .to change { oauth_app.reload.scopes.to_s }
+ .from('openid')
+ .to('profile')
+ end
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 4681f6d..5f5c3ed 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,94 +1,106 @@
-# This file was generated by the `rails generate rspec:install` command. Conventionally, all
-# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
-# The generated `.rspec` file contains `--require spec_helper` which will cause
-# this file to always be loaded, without a need to explicitly require it in any
-# files.
-#
-# Given that it is always loaded, you are encouraged to keep this file as
-# light-weight as possible. Requiring heavyweight dependencies from this file
-# will add to the boot time of your test suite on EVERY test run, even for an
-# individual file that may not need all of that loaded. Instead, consider making
-# a separate helper file that requires the additional dependencies and performs
-# the additional setup, and require it from the spec files that actually need
-# it.
-#
-# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
+ENV['ETSOURCE_DIR'] ||= 'spec/fixtures/etsource'
+
+if ENV["COVERAGE"]
+ require 'simplecov'
+ SimpleCov.start do
+ add_group "ETSource", "app/models/etsource"
+ add_group "Qernel", "app/models/qernel"
+ add_group "GQL", "app/models/gql"
+ #add_group "Controllers", "app/controllers"
+ end
+end
+
+require 'rubygems'
+
+ENV["RAILS_ENV"] ||= 'test'
+require File.expand_path("../../config/environment", __FILE__)
+require 'rspec/rails'
+require 'shoulda/matchers'
+
+require 'view_component/test_helpers'
+
+require 'capybara/rails'
+require 'capybara/rspec'
+
+# Requires supporting ruby files with custom matchers and macros, etc,
+# in spec/support/ and its subdirectories.
+Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
+
+ActiveRecord::Migration.maintain_test_schema!
+
RSpec.configure do |config|
- # rspec-expectations config goes here. You can use an alternate
- # assertion/expectation library such as wrong or the stdlib/minitest
- # assertions if you prefer.
- config.expect_with(:rspec) do |expectations|
- # This option will default to `true` in RSpec 4. It makes the `description`
- # and `failure_message` of custom matchers include text for helper methods
- # defined using `chain`, e.g.:
- # be_bigger_than(2).and_smaller_than(4).description
- # # => "be bigger than 2 and smaller than 4"
- # ...rather than:
- # # => "be bigger than 2"
- expectations.include_chain_clauses_in_custom_matcher_descriptions = true
+ # == Mock Framework
+ #
+ # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
+ #
+ # config.mock_with :mocha
+ # config.mock_with :flexmock
+ # config.mock_with :rr
+ config.mock_with :rspec
+
+ # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
+ config.fixture_path = "#{::Rails.root}/spec/fixtures"
+
+ # If you're not using ActiveRecord, or you'd prefer not to run each of your
+ # examples within a transaction, remove the following line or assign false
+ # instead of true.
+ config.use_transactional_fixtures = true
+
+ # Tries to find examples / groups with the focus tag, and runs them. If no
+ # examples are focues, run everything. Prevents the need to specify
+ # `--tag focus` when you only want to run certain examples.
+ # config.filter_run(push_relabel: true)
+ config.filter_run(focus: true)
+ config.run_all_when_everything_filtered = true
+
+ config.infer_spec_type_from_file_location!
+
+ config.include(FactoryBot::Syntax::Methods)
+
+ config.include(Devise::Test::ControllerHelpers, type: :controller)
+ config.include(AuthorizationHelper)
+
+ config.include(ViewComponentHelpers, type: :component)
+
+ # System tests
+ config.include(SystemHelpers, type: :system)
+
+ config.before(:each, type: :system) do
+ driven_by :rack_test
+ end
+
+ config.before(:each, type: :system, js: true) do
+ driven_by :selenium_chrome_headless
end
- # rspec-mocks config goes here. You can use an alternate test double
- # library (such as bogus or mocha) by changing the `mock_with` option here.
- config.mock_with(:rspec) do |mocks|
- # Prevents you from mocking or stubbing a method that does not exist on
- # a real object. This is generally recommended, and will default to
- # `true` in RSpec 4.
- mocks.verify_partial_doubles = true
+ config.before(:each, type: :system, debug: true) do
+ driven_by :selenium_chrome
end
- # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
- # have no way to turn it off -- the option exists only for backwards
- # compatibility in RSpec 3). It causes shared context metadata to be
- # inherited by the metadata hash of host groups and examples, rather than
- # triggering implicit auto-inclusion in groups with matching metadata.
- config.shared_context_metadata_behavior = :apply_to_host_groups
-
-# The settings below are suggested to provide a good initial experience
-# with RSpec, but feel free to customize to your heart's content.
-=begin
- # This allows you to limit a spec run to individual examples or groups
- # you care about by tagging them with `:focus` metadata. When nothing
- # is tagged with `:focus`, all examples get run. RSpec also provides
- # aliases for `it`, `describe`, and `context` that include `:focus`
- # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
- config.filter_run_when_matching :focus
-
- # Allows RSpec to persist some state between runs in order to support
- # the `--only-failures` and `--next-failure` CLI options. We recommend
- # you configure your source control system to ignore this file.
- config.example_status_persistence_file_path = "spec/examples.txt"
-
- # Limits the available syntax to the non-monkey patched syntax that is
- # recommended. For more details, see:
- # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
- config.disable_monkey_patching!
-
- # Many RSpec users commonly either run the entire suite or an individual
- # file, and it's useful to allow more verbose output when running an
- # individual spec file.
- if config.files_to_run.one?
- # Use the documentation formatter for detailed output,
- # unless a formatter has already been configured
- # (e.g. via a command-line flag).
- config.default_formatter = "doc"
+ # Prevent the static YML file from being deleted.
+ # config.before(:suite) do
+ # loader = ETSourceFixtureHelper::AtlasTestLoader.new(
+ # Rails.root.join('spec/fixtures/etsource/static'))
+
+ # Etsource::Dataset::Import.loader = loader
+
+ # fixture_path = Rails.root.join('spec/fixtures/etsource')
+
+ # Etsource::Base.loader(fixture_path.to_s)
+ # Atlas.data_dir = fixture_path
+ # end
+
+ config.after(:suite) do
+ FileUtils.rm_rf(Rails.root.join('tmp', 'storage'))
end
+end
+
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ # Choose a test framework:
+ with.test_framework :rspec
- # Print the 10 slowest examples and example groups at the
- # end of the spec run, to help surface which specs are running
- # particularly slow.
- config.profile_examples = 10
-
- # Run specs in random order to surface order dependencies. If you find an
- # order dependency and want to debug it, you can fix the order by providing
- # the seed, which is printed after each run.
- # --seed 1234
- config.order = :random
-
- # Seed global randomization in this process using the `--seed` CLI option.
- # Setting this allows you to use `--seed` to deterministically reproduce
- # test failures related to randomization by passing the same `--seed` value
- # as the one that triggered the failure.
- Kernel.srand config.seed
-=end
+ # Choose one or more libraries:
+ with.library :rails
+ end
end
diff --git a/spec/support/authorization_helper.rb b/spec/support/authorization_helper.rb
new file mode 100644
index 0000000..2436ee2
--- /dev/null
+++ b/spec/support/authorization_helper.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module AuthorizationHelper
+ def access_token_header(user, scopes, expires_in: 1.hour)
+ scopes =
+ case scopes
+ when :public
+ 'public'
+ when :read
+ 'public scenarios:read'
+ when :write
+ 'public scenarios:read scenarios:write'
+ when :delete
+ 'public scenarios:read scenarios:write scenarios:delete'
+ else
+ if scopes.is_a?(Symbol)
+ raise "Unknown scope alias #{scopes.inspect}, expected :public, :read, :write, " \
+ ':delete or a string'
+ end
+
+ scopes.to_s
+ end
+
+ token = create(:access_token, resource_owner_id: user.id, scopes:, expires_in: expires_in.to_i)
+ { 'Authorization' => "Bearer #{token.token}" }
+ end
+
+ def stub_faraday_422(body)
+ faraday_response = instance_double(Faraday::Response)
+ allow(faraday_response).to receive(:[]).with(:body).and_return('errors' => body)
+
+ Faraday::UnprocessableEntityError.new(nil, faraday_response)
+ end
+end
diff --git a/spec/support/system_helpers.rb b/spec/support/system_helpers.rb
new file mode 100644
index 0000000..a072115
--- /dev/null
+++ b/spec/support/system_helpers.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module SystemHelpers
+ RSpec::Matchers.define(:be_signed_in_page) do
+ match do |actual|
+ have_css('header button', text: 'Sign out').matches?(actual)
+ end
+ end
+
+ def sign_in(user_or_email, password = nil)
+ if user_or_email.is_a?(User)
+ password = user_or_email.password
+ user_or_email = user_or_email.email
+ end
+
+ visit('/identity/sign_in')
+
+ fill_in('E-mail', with: user_or_email)
+ fill_in('Password', with: password)
+
+ click_button('Continue')
+ end
+end
diff --git a/spec/support/view_component_helpers.rb b/spec/support/view_component_helpers.rb
new file mode 100644
index 0000000..4bfe4f4
--- /dev/null
+++ b/spec/support/view_component_helpers.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ViewComponentHelpers
+ extend ActiveSupport::Concern
+
+ included do
+ include ViewComponent::TestHelpers
+ include Capybara::RSpecMatchers
+ include Devise::Test::IntegrationHelpers
+ end
+
+ # Make a template method available for RSpec matchers.
+ def template
+ lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths)
+ ActionView::Base.new(lookup_context, {}, ApplicationController.new)
+ end
+end
diff --git a/spec/system/create_personal_access_token_spec.rb b/spec/system/create_personal_access_token_spec.rb
new file mode 100644
index 0000000..7b5d63e
--- /dev/null
+++ b/spec/system/create_personal_access_token_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Revoking a personal access token', type: :system do
+ context 'with valid params' do
+ it 'creates a token' do
+ user = create(:user)
+ sign_in(user)
+
+ visit '/identity/tokens/new'
+
+ fill_in 'Token name', with: 'API access'
+ select '7 days', from: 'Expiration'
+ choose 'Read public and private scenarios'
+
+ click_button 'Create token'
+
+ expect(page).to have_content('Token created')
+ expect(page).to have_content('API access')
+
+ # Scopes
+
+ expect(page).to have_css(
+ '[data-testid="scope:public"]:not([aria-hidden="true"])',
+ text: 'Read public scenarios'
+ )
+
+ expect(page).to have_css(
+ '[data-testid="scope:scenarios:read"]:not([aria-hidden="true"])',
+ text: 'Read your private scenarios'
+ )
+
+ expect(page).to have_css(
+ '[data-testid="scope:scenarios:write"][aria-hidden="true"]',
+ text: 'Create new scenarios and change your public and private scenarios'
+ )
+
+ expect(page).to have_css(
+ '[data-testid="scope:scenarios:delete"][aria-hidden="true"]',
+ text: 'Delete your public and private scenarios'
+ )
+ end
+ end
+
+ context 'with no name' do
+ it 'creates a token' do
+ user = create(:user)
+ sign_in(user)
+
+ visit '/identity/tokens/new'
+
+ fill_in 'Token name', with: ''
+ select '7 days', from: 'Expiration'
+ choose 'Read public and private scenarios'
+
+ click_button 'Create token'
+
+ expect(page).to have_content("Name can't be blank")
+ end
+ end
+end
diff --git a/spec/system/delete_account_spec.rb b/spec/system/delete_account_spec.rb
new file mode 100644
index 0000000..729b0cc
--- /dev/null
+++ b/spec/system/delete_account_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Registrations', type: :system do
+ let(:user) { create(:user) }
+
+ pending 'allows deleting the account' do
+ sign_in(user)
+
+ # Create some data for the user.
+ create(:scenario, user: user)
+ create(:scenario, user: user)
+ create(:personal_access_token, user:)
+
+ visit '/identity'
+
+ click_link 'Delete account'
+
+ expect(page).to have_text('You are about to delete your account!')
+ expect(page).to have_text('2 scenarios')
+ expect(page).to have_text('10 saved scenarios')
+ expect(page).to have_text('3 transition paths')
+ expect(page).to have_text('One personal access token')
+
+ fill_in 'Password', with: user.password
+
+ begin
+ click_button 'Permanently delete account'
+ rescue ActionController::RoutingError
+ # This is raised because it redirects to ETModel, which isn't available in tests.
+ end
+
+ expect(User.where(id: user.id).count).to eq(0)
+ end
+
+ pending 'shows an error when entering an invalid password' do
+ sign_in(user)
+
+ visit '/identity'
+
+ click_link 'Delete account'
+
+ fill_in 'Password', with: '_invalid_'
+ click_button 'Permanently delete account'
+
+ expect(page).to have_text('Current password is invalid')
+ end
+
+ pending 'shows an error when entering no password' do
+ sign_in(user)
+
+ visit '/identity'
+
+ click_link 'Delete account'
+
+ fill_in 'Password', with: ''
+ click_button 'Permanently delete account'
+
+ expect(page).to have_text("Current password can't be blank")
+ end
+end
diff --git a/spec/system/locale_spec.rb b/spec/system/locale_spec.rb
new file mode 100644
index 0000000..7ccd363
--- /dev/null
+++ b/spec/system/locale_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Locales', type: :system do
+ it 'allows switching the language' do
+ sign_in(create(:user))
+
+ visit '/identity/profile'
+ expect(page).to have_css('header button', text: 'Sign out')
+
+ visit '/identity/profile?locale=nl'
+ expect(page).to have_css('header button', text: 'Uitloggen')
+
+ visit '/identity/profile?locale=en'
+ expect(page).to have_css('header button', text: 'Sign out')
+
+ visit '/identity/profile?locale=de'
+ expect(page).to have_css('header button', text: 'Sign out')
+ ensure
+ I18n.locale = :en # rubocop:disable Rails/I18nLocaleAssignment
+ end
+end
diff --git a/spec/system/registrations_spec.rb b/spec/system/registrations_spec.rb
new file mode 100644
index 0000000..43f3b55
--- /dev/null
+++ b/spec/system/registrations_spec.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+# TODO: Pending as capybara is not picking up the buttons correctly yet
+
+RSpec.describe 'Registrations', type: :system do
+ before do
+ ActionMailer::Base.deliveries.clear
+ end
+
+ # Given a Mail::Message, extracts the confirmation path from the message body.
+ def confirmation_url_from_email(email)
+ body = email.body
+ match = body.match(%r{http://localhost:3002(/identity/confirmation\?confirmation_token=[^"]+)})
+
+ raise 'No reset password link found' unless match
+
+ match[1]
+ end
+
+ # Registering an account
+ # ----------------------
+
+ pending 'when registering an account' do
+ it 'allows registering an account' do
+ visit '/identity/sign_up'
+
+ fill_in 'Your name', with: 'My name'
+ fill_in 'E-mail address', with: 'me@example.org'
+ fill_in 'Password', with: 'password123'
+
+ click_button 'Sign up'
+
+ visit '/identity'
+
+ expect(page).to be_signed_in_page
+
+ # Confirm
+ visit confirmation_url_from_email(ActionMailer::Base.deliveries.first)
+
+ expect(page).to have_text('Your email address has been successfully confirmed')
+ expect(page).to have_text('My name')
+ end
+
+ it 'shows an error when registering without a name' do
+ visit '/identity/sign_up'
+
+ fill_in 'E-mail address', with: 'me@example.org'
+ fill_in 'Password', with: 'password123'
+ click_button 'Sign up'
+
+ expect(page).to have_text("Your name can't be blank")
+ end
+
+ it 'shows an error when registering without a password' do
+ visit '/identity/sign_up'
+
+ fill_in 'Your name', with: 'My name'
+ fill_in 'E-mail address', with: 'me@example.org'
+ click_button 'Sign up'
+
+ expect(page).to have_text("Password can't be blank")
+ end
+
+ it 'shows an error when registering without an e-mail address' do
+ visit '/identity/sign_up'
+
+ fill_in 'Your name', with: 'My name'
+ fill_in 'Password', with: 'password123'
+ click_button 'Sign up'
+
+ expect(page).to have_text("E-mail address can't be blank")
+ end
+
+ it 'shows an error when registering with an existing e-mail address' do
+ create(:user, email: 'me@example.org')
+
+ visit '/identity/sign_up'
+
+ fill_in 'Your name', with: 'My name'
+ fill_in 'E-mail address', with: 'me@example.org'
+ fill_in 'Password', with: 'password123'
+ click_button 'Sign up'
+
+ expect(page).to have_text('E-mail address has already been taken')
+ end
+ end
+
+ # Changing name
+ # ------------------
+
+ pending 'when changing the account name' do
+ let(:user) do
+ create(:user, name: 'John Doe')
+ end
+
+ before do
+ allow(Identity::SyncUserJob).to receive(:perform_later)
+ end
+
+ context 'when visiting signed out' do
+ it 'tells the user to sign in' do
+ visit '/identity/change_name'
+ expect(page).to have_text('You need to sign in or sign up before continuing.')
+ end
+ end
+
+ context 'when providing a valid name' do
+ before do
+ sign_in(user)
+ end
+
+ it 'changes the password and redirects' do
+ click_link 'Change name…'
+
+ fill_in 'New name', with: 'Jane Doe'
+
+ click_button 'Change name'
+
+ expect(page).to have_text('Name changed')
+ expect(page).to be_signed_in_page
+ end
+ end
+
+ context 'when providing a blank name' do
+ before { sign_in(user) }
+
+ it 'shows an error' do
+ visit '/identity/change_name'
+
+ fill_in 'New name', with: ''
+
+ click_button 'Change name'
+
+ expect(page).to have_text("Your name can't be blank")
+ end
+ end
+ end
+
+ # Changing e-mail address
+ # -----------------------
+
+ pending 'when changing e-mail address' do
+ let(:user) do
+ create(:user, email: 'hello@example.org', password: 'password123')
+ end
+
+ context 'when visiting signed out' do
+ it 'tells the user to sign in' do
+ visit '/identity/change_password'
+ expect(page).to have_text('You need to sign in or sign up before continuing.')
+ end
+ end
+
+ context 'when providing the correct current password and a valid new address' do
+ before do
+ sign_in(user)
+ end
+
+ it 'changes the address and redirects' do
+ click_link 'Change e-mail address…'
+
+ fill_in 'Current password', with: 'password123'
+ fill_in 'New e-mail address', with: 'hi@example.org'
+
+ click_button 'Change e-mail address'
+
+ expect(page).to have_text('E‑mail changed')
+ expect(page).to be_signed_in_page
+ end
+ end
+
+ context 'when providing the incorrect current password and a valid new address' do
+ before { sign_in(user) }
+
+ it 'shows an error' do
+ click_link 'Change e-mail address…'
+
+ fill_in 'Current password', with: 'password456'
+ fill_in 'New e-mail address', with: 'hi@example.org'
+
+ click_button 'Change e-mail address'
+
+ expect(page).to have_text('Current password is invalid')
+ end
+ end
+
+ context 'when providing the correct current password and an invalid new address' do
+ before { sign_in(user) }
+
+ it 'shows an error' do
+ click_link 'Change e-mail address…'
+
+ fill_in 'Current password', with: 'password123'
+ fill_in 'New e-mail address', with: ''
+
+ click_button 'Change e-mail address'
+
+ expect(page).to have_text("E-mail address can't be blank")
+ end
+ end
+ end
+
+ # Changing password
+ # ------------------
+
+ pending 'when changing password' do
+ let(:user) do
+ create(:user, password: 'password123')
+ end
+
+ context 'when visiting signed out' do
+ it 'tells the user to sign in' do
+ visit '/identity/change_password'
+ expect(page).to have_text('You need to sign in or sign up before continuing.')
+ end
+ end
+
+ context 'when providing the correct current password and a valid new password' do
+ before do
+ sign_in(user)
+ end
+
+ it 'changes the password and redirects' do
+ click_link 'Change password…'
+
+ fill_in 'Current password', with: 'password123'
+ fill_in 'New password', with: 'password456'
+
+ click_button 'Change password'
+
+ expect(page).to have_text('Password changed')
+ expect(page).to be_signed_in_page
+ end
+ end
+
+ context 'when providing the incorrect current password and a valid new password' do
+ before { sign_in(user) }
+
+ it 'shows an error' do
+ visit '/identity/change_password'
+
+ fill_in 'Current password', with: 'password456'
+ fill_in 'New password', with: 'password456'
+
+ click_button 'Change password'
+
+ expect(page).to have_text('Current password is invalid')
+ end
+ end
+
+ context 'when providing the correct current password and an invalid new password' do
+ before { sign_in(user) }
+
+ it 'shows an error' do
+ visit '/identity/change_password'
+
+ fill_in 'Current password', with: 'password123'
+ fill_in 'New password', with: 'pa'
+
+ click_button 'Change password'
+
+ expect(page).to have_text('Password is too short (minimum is 8 characters)')
+ end
+ end
+ end
+end
diff --git a/spec/system/revoke_personal_token_spec.rb b/spec/system/revoke_personal_token_spec.rb
new file mode 100644
index 0000000..ed128aa
--- /dev/null
+++ b/spec/system/revoke_personal_token_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Revoking a personal access token', type: :system do
+ it 'revokes the token' do
+ user = create(:user)
+ CreatePersonalAccessToken.call(user:, params: { name: 'API access' })
+
+ sign_in(user)
+
+ visit '/identity/tokens'
+
+ expect(page).to have_content('API access')
+
+ click_button 'Revoke token'
+ expect(page).to have_content('You have no access tokens')
+ end
+end
diff --git a/spec/system/sessions_spec.rb b/spec/system/sessions_spec.rb
new file mode 100644
index 0000000..2bb6c20
--- /dev/null
+++ b/spec/system/sessions_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Sessions', type: :system do
+ pending 'allows signing in as a user' do
+ create(:user, email: 'hello@example.org', password: 'password123')
+
+ visit '/identity/sign_in'
+
+ fill_in 'E-mail address', with: 'hello@example.org'
+ fill_in 'Password', with: 'password123'
+
+ click_button 'Continue'
+
+ expect(page).to be_signed_in_page
+ end
+
+ it 'does not sign in when providing an invalid e-mail' do
+ visit '/identity/sign_in'
+
+ fill_in 'E-mail address', with: 'hello@example.org'
+ fill_in 'Password', with: 'password123'
+
+ click_button 'Continue'
+
+ expect(page).to have_text('Sign in to your account to continue')
+ end
+
+ it 'does not sign in when providing an invalid password' do
+ create(:user, email: 'hello@example.org', password: 'password123')
+
+ visit '/identity/sign_in'
+
+ fill_in 'E-mail address', with: 'hello@example.org'
+ fill_in 'Password', with: 'password456'
+
+ click_button 'Continue'
+
+ expect(page).to have_text('Sign in to your account to continue')
+ end
+
+ it 'does not sign in when providing no credentials' do
+ visit '/identity/sign_in'
+
+ fill_in 'E-mail address', with: ''
+ fill_in 'Password', with: ''
+
+ click_button 'Continue'
+
+ expect(page).to have_text('Sign in to your account to continue')
+ end
+end
diff --git a/spec/system/staff_application_spec.rb b/spec/system/staff_application_spec.rb
new file mode 100644
index 0000000..b431d66
--- /dev/null
+++ b/spec/system/staff_application_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Staff application', type: :system do
+ pending 'allows creating a new staff application' do
+ admin = create(:admin)
+ sign_in(admin)
+
+ visit '/'
+
+ within('#staff_application_etmodel') do
+ fill_in 'Hosted at', with: 'https://example.com'
+ click_button 'Create application'
+ end
+
+ oauth_app = admin.staff_applications.find_by!(name: 'etmodel').application
+
+ expect(page).to have_content('The application was updated.')
+ expect(page).to have_content(oauth_app.uid)
+ expect(page).to have_content(oauth_app.secret)
+ end
+
+ pending 'allows updating a new staff application' do
+ admin = create(:admin)
+ CreateStaffApplication.call(admin, MyEtm::StaffApplications.find('etmodel'))
+
+ sign_in(admin)
+
+ visit '/'
+
+ within('#staff_application_etmodel') do
+ fill_in 'Hosted at', with: 'https://my-site.test'
+ click_button 'Change'
+ end
+
+ oauth_app = admin.staff_applications.find_by!(name: 'etmodel').application
+
+ expect(page).to have_content('The application was updated.')
+ expect(page).to have_content(oauth_app.uid)
+ expect(page).to have_content(oauth_app.secret)
+
+ oauth_app.reload
+
+ expect(oauth_app.uri).to eq('https://my-site.test')
+ expect(oauth_app.redirect_uri).to eq('https://my-site.test/auth/identity/callback')
+ end
+end