diff --git a/.github/workflows/brakeman.yml b/.github/workflows/brakeman.yml index ca55cdbbe3..c6c0935a77 100644 --- a/.github/workflows/brakeman.yml +++ b/.github/workflows/brakeman.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Will run Brakeman checks on dependencies # https://github.com/marketplace/actions/brakeman-action diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index c7d111d97e..29a265c861 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Install Ruby and run bundler - uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/docker-push-image.yml b/.github/workflows/docker-push-image.yml index 96c99fcbc4..06cd8dae9b 100644 --- a/.github/workflows/docker-push-image.yml +++ b/.github/workflows/docker-push-image.yml @@ -19,14 +19,14 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out the repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Buildx allows for advanced Docker build features like multi-platform builds and layer caching - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -34,7 +34,7 @@ jobs: # Extract metadata for app image - name: Extract metadata (tags) for Docker app Image id: meta_app - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ualbertalib/dmp_roadmap flavor: | @@ -55,7 +55,7 @@ jobs: # Extract metadata for assets image - name: Extract metadata (tags) for Docker assets image id: meta_assets - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ualbertalib/dmp_roadmap flavor: | @@ -75,7 +75,7 @@ jobs: - name: Build and push the app stage image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 with: context: . file: Dockerfile.production @@ -89,7 +89,7 @@ jobs: cache-to: type=registry,ref=ualbertalib/dmp_roadmap:build-cache-app,mode=max - name: Build and push the assets stage image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 with: context: . file: Dockerfile.production diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index 58f7c86714..51a04f0dc7 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -8,8 +8,8 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: '20.15.1' cache: 'yarn' diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml index df69278319..4add0de392 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -14,7 +14,7 @@ jobs: steps: # Checkout the repo - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Install Ruby and run bundler - uses: ruby/setup-ruby@v1 @@ -23,7 +23,7 @@ jobs: bundler-cache: true # Install Node - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: '20.15.1' cache: 'yarn' diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index cfed518d35..edad58ba01 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -30,7 +30,7 @@ jobs: steps: # Checkout the repo - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Install Ruby and run bundler - uses: ruby/setup-ruby@v1 @@ -42,7 +42,7 @@ jobs: ## /home/runner/runners/2.301.1/externals/node12/bin/node: --openssl-legacy-provider is not allowed in NODE_OPTIONS # Install Node - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: '20.15.1' cache: 'yarn' @@ -82,7 +82,7 @@ jobs: - name: Remove image-bundled Chrome run: sudo apt-get purge google-chrome-stable - name: Setup stable Chrome - uses: browser-actions/setup-chrome@v1 + uses: browser-actions/setup-chrome@v2 with: chrome-version: 128 install-chromedriver: true diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index 9a708d4c9f..f893ec927d 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: 'Determine Ruby and Bundler Versions from Gemfile.lock' run: | diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 5975b8d3bb..1cb13e5aed 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -40,7 +40,7 @@ jobs: steps: # Checkout the repo - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 # Install Ruby and run bundler - uses: ruby/setup-ruby@v1 @@ -49,7 +49,7 @@ jobs: bundler-cache: true # Install Node - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: '20.15.1' cache: 'yarn' @@ -86,7 +86,7 @@ jobs: - name: Remove image-bundled Chrome run: sudo apt-get purge google-chrome-stable - name: Setup stable Chrome - uses: browser-actions/setup-chrome@v1 + uses: browser-actions/setup-chrome@v2 with: chrome-version: 128 install-chromedriver: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a0af4322..e6d5feb408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Changelog +## [Unreleased] + +### Added + + - Add rake task to clean up unmanaged orgs w/ users [#1250](https://github.com/portagenetwork/roadmap/pull/1250) + + - Implement tiered Rack::Attack throttles [#1254](https://github.com/portagenetwork/roadmap/pull/1254) + + - Add Internal v2 API Access Token Generation for Users [#1279](https://github.com/portagenetwork/roadmap/pull/1279) + + - Add `bundle exec rails db:migrate` to entrypoint.sh [#1278](https://github.com/portagenetwork/roadmap/pull/1278) + + - Add copy button next to V2 API Token [#1283](https://github.com/portagenetwork/roadmap/pull/1283) + + - Initial v2 API Implementation & Doorkeeper OAuth Integration [#1276](https://github.com/portagenetwork/roadmap/pull/1276) + + - API v2: Sanitize user-supplied fields in responses [#1303](https://github.com/portagenetwork/roadmap/pull/1303) + + - Add v2 API documentation to API Access page [#1300](https://github.com/portagenetwork/roadmap/pull/1300) + +### Changed + + - Upgrade ROR API From V1 to V2 [#1247](https://github.com/portagenetwork/roadmap/pull/1247) + + - Update plan json export to use V2 API complete plan endpoint [#1293](https://github.com/portagenetwork/roadmap/pull/1293) + + - Redesign and Edit Welcome, Help, and About pages and Remove Public DMPs Page [#1299](https://github.com/portagenetwork/roadmap/pull/1299) + + - Update expiry time of v2 API internal access token [#1308](https://github.com/portagenetwork/roadmap/pull/1308) + +### Dependency Updates + + - chore(deps): bump nginx from 1.29.3-alpine to 1.29.4-alpine [#1246](https://github.com/portagenetwork/roadmap/pull/1246) + + - chore(deps): bump actions/checkout from 5 to 6 [#1236](https://github.com/portagenetwork/roadmap/pull/1236) + + - chore(deps): bump omniauth from 2.1.2 to 2.1.4 [#1201](https://github.com/portagenetwork/roadmap/pull/1201) + + - chore(deps): bump browser-actions/setup-chrome from 1 to 2 [#1138](https://github.com/portagenetwork/roadmap/pull/1138) + + - chore(deps): bump webpack from 5.94.0 to 5.102.1 [#1206](https://github.com/portagenetwork/roadmap/pull/1206) + + - chore(deps): bump actions/setup-node from 5 to 6 [#1211](https://github.com/portagenetwork/roadmap/pull/1211) + + - chore(deps): bump surnet/alpine-wkhtmltopdf from 3.22.0-0.12.6-small to 3.23.2-0.12.6-small [#1258](https://github.com/portagenetwork/roadmap/pull/1258) + + - chore(deps): bump docker/login-action from 3.6.0 to 3.7.0 [#1268](https://github.com/portagenetwork/roadmap/pull/1268) + + - chore(deps): bump docker/build-push-action from 6.18.0 to 6.19.2 [#1277](https://github.com/portagenetwork/roadmap/pull/1277) + + - chore(deps): bump nginx from 1.29.4-alpine to 1.29.5-alpine [#1271](https://github.com/portagenetwork/roadmap/pull/1271) + + - `bundle update httparty` [#1291](https://github.com/portagenetwork/roadmap/pull/1291) + - (See [#1288](https://github.com/portagenetwork/roadmap/pull/1288) for more) + + - chore(deps): bump docker/login-action from 3.7.0 to 4.0.0 [#1290](https://github.com/portagenetwork/roadmap/pull/1290) + + - chore(deps): bump docker/setup-buildx-action from 3 to 4 [#1295](https://github.com/portagenetwork/roadmap/pull/1295) + + - chore(deps): bump docker/metadata-action from 5 to 6 [#1298](https://github.com/portagenetwork/roadmap/pull/1298) + + - chore(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 [#1297](https://github.com/portagenetwork/roadmap/pull/1297) + + - chore(deps): bump nginx from 1.29.5-alpine to 1.29.6-alpine [#1302](https://github.com/portagenetwork/roadmap/pull/1302) + + - chore(deps): bump eslint-plugin-import from 2.27.5 to 2.32.0 [#1149](https://github.com/portagenetwork/roadmap/pull/1149) + ## [4.1.1+portage-4.6.2] ### Added diff --git a/Dockerfile.production b/Dockerfile.production index 628d6ba86f..2721217154 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,4 +1,4 @@ -FROM surnet/alpine-wkhtmltopdf:3.22.0-0.12.6-small AS wkhtmltopdf +FROM surnet/alpine-wkhtmltopdf:3.23.2-0.12.6-small AS wkhtmltopdf FROM ruby:3.1.4-alpine AS builder @@ -139,7 +139,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ ENTRYPOINT ["/usr/bin/entrypoint.sh"] # nginx stage to serve static assets -FROM nginx:1.29.3-alpine AS assets +FROM nginx:1.29.6-alpine AS assets ENV INSTALL_PATH=/usr/src/app diff --git a/Gemfile b/Gemfile index fcc7af1783..9414e12657 100644 --- a/Gemfile +++ b/Gemfile @@ -88,6 +88,8 @@ gem 'devise' # An invitation strategy for Devise (https://github.com/scambra/devise_invitable) gem 'devise_invitable' +gem 'doorkeeper' + # A generalized Rack framework for multiple-provider authentication. # (https://github.com/omniauth/omniauth) gem 'omniauth' diff --git a/Gemfile.lock b/Gemfile.lock index 9b6cf44b43..1ed7eea62e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,722 +1,726 @@ -GIT - remote: https://github.com/ualbertalib/translation_io_rails - revision: f60a5427372b51348eb218755e275f0f34d19746 - specs: - translation (1.22) - gettext (~> 3.2, >= 3.2.5) - -GEM - remote: https://rubygems.org/ - specs: - actioncable (6.1.7.9) - actionpack (= 6.1.7.9) - activesupport (= 6.1.7.9) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.9) - actionpack (= 6.1.7.9) - activejob (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - mail (>= 2.7.1) - actionmailer (6.1.7.9) - actionpack (= 6.1.7.9) - actionview (= 6.1.7.9) - activejob (= 6.1.7.9) - activesupport (= 6.1.7.9) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.9) - actionview (= 6.1.7.9) - activesupport (= 6.1.7.9) - rack (~> 2.0, >= 2.0.9) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.9) - actionpack (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - nokogiri (>= 1.8.5) - actionview (6.1.7.9) - activesupport (= 6.1.7.9) - builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.9) - activesupport (= 6.1.7.9) - globalid (>= 0.3.6) - activemodel (6.1.7.9) - activesupport (= 6.1.7.9) - activerecord (6.1.7.9) - activemodel (= 6.1.7.9) - activesupport (= 6.1.7.9) - activerecord-nulldb-adapter (1.1.1) - activerecord (>= 6.0, < 8.1) - activerecord_json_validator (2.1.5) - activerecord (>= 4.2.0, < 8) - json_schemer (~> 0.2.18) - activestorage (6.1.7.9) - actionpack (= 6.1.7.9) - activejob (= 6.1.7.9) - activerecord (= 6.1.7.9) - activesupport (= 6.1.7.9) - marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.9) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - aes_key_wrap (1.1.0) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) - annotate_gem (0.0.14) - bundler (>= 1.1) - api-pagination (5.0.0) - ast (2.4.2) - attr_required (1.0.2) - autoprefixer-rails (10.4.19.0) - execjs (~> 2) - base64 (0.1.2) - bcrypt (3.1.20) - better_errors (2.10.1) - erubi (>= 1.0.0) - rack (>= 0.9.0) - rouge (>= 1.0.0) - bigdecimal (3.3.1) - bindata (2.5.0) - bindex (0.8.1) - binding_of_caller (1.0.1) - debug_inspector (>= 1.2.0) - bootsnap (1.18.3) - msgpack (~> 1.2) - brakeman (7.0.0) - racc - builder (3.3.0) - bullet (7.1.6) - activesupport (>= 3.0.0) - uniform_notifier (~> 1.11) - bundle-audit (0.1.0) - bundler-audit - bundler-audit (0.9.1) - bundler (>= 1.2.0, < 3) - thor (~> 1.0) - byebug (11.1.3) - capybara (3.39.2) - addressable - matrix - mini_mime (>= 0.1.3) - nokogiri (~> 1.8) - rack (>= 1.6.0) - rack-test (>= 0.6.3) - regexp_parser (>= 1.5, < 3.0) - xpath (~> 3.2) - claide (1.1.0) - claide-plugins (0.9.2) - cork - nap - open4 (~> 1.3) - coderay (1.1.3) - colored2 (3.1.2) - concurrent-ruby (1.3.4) - contact_us (1.2.0) - rails (>= 4.2.0) - cork (0.3.0) - colored2 (~> 3.1) - crack (1.0.0) - bigdecimal - rexml - crass (1.0.6) - cssbundling-rails (1.4.1) - railties (>= 6.0.0) - csv (3.3.5) - danger (9.4.3) - claide (~> 1.0) - claide-plugins (>= 0.9.2) - colored2 (~> 3.1) - cork (~> 0.1) - faraday (>= 0.9.0, < 3.0) - faraday-http-cache (~> 2.0) - git (~> 1.13) - kramdown (~> 2.3) - kramdown-parser-gfm (~> 1.0) - no_proxy_fix - octokit (>= 4.0) - terminal-table (>= 1, < 4) - database_cleaner (2.0.2) - database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.1.0) - activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) - database_cleaner-core (2.0.1) - date (3.4.1) - debug_inspector (1.2.0) - devise (4.9.4) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) - devise_invitable (2.0.9) - actionmailer (>= 5.0) - devise (>= 4.6) - diff-lcs (1.5.1) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) - dragonfly (1.4.1) - addressable (~> 2.3) - multi_json (~> 1.0) - ostruct (~> 0.6.1) - rack (>= 1.3) - dragonfly-s3_data_store (1.3.0) - dragonfly (~> 1.0) - fog-aws - ecma-re-validator (0.4.0) - regexp_parser (~> 2.2) - email_validator (2.2.4) - activemodel - erubi (1.13.1) - excon (0.104.0) - execjs (2.10.0) - factory_bot (6.2.1) - activesupport (>= 5.0.0) - factory_bot_rails (6.2.0) - factory_bot (~> 6.2.0) - railties (>= 5.0.0) - faker (3.4.1) - i18n (>= 1.8.11, < 2) - faraday (2.9.0) - faraday-net_http (>= 2.0, < 3.2) - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) - faraday-http-cache (2.5.1) - faraday (>= 0.8) - faraday-net_http (3.1.0) - net-http - ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86_64-linux-gnu) - ffi (1.17.2-x86_64-linux-musl) - flag_shih_tzu (0.3.23) - fog-aws (3.21.0) - fog-core (~> 2.1) - fog-json (~> 1.1) - fog-xml (~> 0.1) - fog-core (2.3.0) - builder - excon (~> 0.71) - formatador (>= 0.2, < 2.0) - mime-types - fog-json (1.2.0) - fog-core - multi_json (~> 1.10) - fog-xml (0.1.4) - fog-core - nokogiri (>= 1.5.11, < 2.0.0) - formatador (1.1.0) - forwardable (1.3.3) - fuubar (2.5.1) - rspec-core (~> 3.0) - ruby-progressbar (~> 1.4) - gettext (3.5.1) - erubi - locale (>= 2.0.5) - prime - racc - text (>= 1.3.0) - git (1.19.1) - addressable (~> 2.8) - rchardet (~> 1.8) - globalid (1.2.1) - activesupport (>= 6.1) - guard (2.19.1) - formatador (>= 0.2.4) - listen (>= 2.7, < 4.0) - logger (~> 1.6) - lumberjack (>= 1.0.12, < 2.0) - nenv (~> 0.1) - notiffany (~> 0.0) - ostruct (~> 0.6) - pry (>= 0.13.0) - shellany (~> 0.0) - thor (>= 0.18.1) - hana (1.3.7) - hashdiff (1.1.0) - hashie (5.0.0) - highline (3.1.2) - reline - htmltoword (1.1.1) - actionpack - nokogiri - rubyzip (>= 1.0) - httparty (0.24.0) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) - i18n (1.14.6) - concurrent-ruby (~> 1.0) - io-console (0.8.0) - jbuilder (2.12.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) - jsbundling-rails (1.3.0) - railties (>= 6.0.0) - json (2.7.2) - json-jwt (1.16.6) - activesupport (>= 4.2) - aes_key_wrap - base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects - json_schemer (0.2.25) - ecma-re-validator (~> 0.3) - hana (~> 1.3) - regexp_parser (~> 2.0) - simpleidn (~> 0.2) - uri_template (~> 0.7) - jwt (2.10.1) - base64 - kaminari (1.2.2) - activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.2) - kaminari-activerecord (= 1.2.2) - kaminari-core (= 1.2.2) - kaminari-actionview (1.2.2) - actionview - kaminari-core (= 1.2.2) - kaminari-activerecord (1.2.2) - activerecord - kaminari-core (= 1.2.2) - kaminari-core (1.2.2) - kramdown (2.4.0) - rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) - language_server-protocol (3.17.0.3) - ledermann-rails-settings (2.6.2) - activerecord (>= 6.1) - listen (3.9.0) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) - locale (2.1.4) - logger (1.7.0) - loofah (2.24.0) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - lumberjack (1.2.10) - mail (2.8.1) - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.0.4) - matrix (0.4.2) - method_source (1.1.0) - mime-types (3.5.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2023.1003) - mimemagic (0.4.3) - nokogiri (~> 1) - rake - mini_mime (1.1.5) - minitest (5.25.4) - mocha (2.7.1) - ruby2_keywords (>= 0.0.5) - msgpack (1.7.2) - multi_json (1.15.0) - multi_xml (0.7.1) - bigdecimal (~> 3.1) - mysql2 (0.5.6) - nap (1.1.0) - nenv (0.3.0) - net-http (0.4.1) - uri - net-imap (0.4.20) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.2.2) - timeout - net-smtp (0.5.0) - net-protocol - nio4r (2.7.4) - no_proxy_fix (0.1.2) - nokogiri (1.18.9-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-musl) - racc (~> 1.4) - notiffany (0.1.3) - nenv (~> 0.1) - shellany (~> 0.0) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) - multi_xml (~> 0.5) - rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - octokit (8.1.0) - base64 - faraday (>= 1, < 3) - sawyer (~> 0.9) - omniauth (2.1.2) - hashie (>= 3.4.6) - rack (>= 2.2.3) - rack-protection - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) - omniauth (~> 2.0) - omniauth-orcid (2.1.1) - omniauth-oauth2 (~> 1.3) - ruby_dig (~> 0.0.2) - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth-shibboleth (1.3.0) - omniauth (>= 1.0.0) - omniauth_openid_connect (0.7.1) - omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - open4 (1.3.4) - openid_connect (2.3.0) - activemodel - attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) - tzinfo - validate_url - webfinger (~> 2.0) - options (2.3.2) - orm_adapter (0.5.0) - ostruct (0.6.1) - parallel (1.26.3) - parser (3.2.2.4) - ast (~> 2.4.1) - racc - pg (1.5.9) - prime (0.1.4) - forwardable - singleton - progress_bar (1.3.4) - highline (>= 1.6) - options (~> 2.3.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) - public_suffix (6.0.1) - puma (6.6.0) - nio4r (~> 2.0) - pundit (2.3.2) - activesupport (>= 3.0.0) - pundit-matchers (4.0.0) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - racc (1.8.1) - rack (2.2.18) - rack-attack (6.7.0) - rack (>= 1.0, < 4) - rack-mini-profiler (3.3.1) - rack (>= 1.2.0) - rack-oauth2 (2.2.1) - activesupport - attr_required - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.11.0) - rack (>= 2.1.0) - rack-protection (3.2.0) - base64 (>= 0.1.0) - rack (~> 2.2, >= 2.2.4) - rack-test (2.2.0) - rack (>= 1.3) - rails (6.1.7.9) - actioncable (= 6.1.7.9) - actionmailbox (= 6.1.7.9) - actionmailer (= 6.1.7.9) - actionpack (= 6.1.7.9) - actiontext (= 6.1.7.9) - actionview (= 6.1.7.9) - activejob (= 6.1.7.9) - activemodel (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - bundler (>= 1.15.0) - railties (= 6.1.7.9) - sprockets-rails (>= 2.0.0) - rails-controller-testing (1.0.5) - actionpack (>= 5.0.1.rc1) - actionview (>= 5.0.1.rc1) - activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.2.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (6.1.7.9) - actionpack (= 6.1.7.9) - activesupport (= 6.1.7.9) - method_source - rake (>= 12.2) - thor (~> 1.0) - rainbow (3.1.1) - rake (13.2.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) - rchardet (1.8.0) - recaptcha (5.18.0) - regexp_parser (2.8.2) - reline (0.6.1) - io-console (~> 0.5) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.4.2) - rollbar (3.5.2) - rouge (4.1.3) - rspec-collection_matchers (1.2.1) - rspec-expectations (>= 2.99.0.beta1) - rspec-core (3.13.2) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-rails (6.1.5) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.2) - rubocop (1.57.1) - base64 (~> 0.1.1) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.2.2.4) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.1, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) - rubocop-i18n (3.0.0) - rubocop (~> 1.0) - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) - ruby_dig (0.0.2) - rubyzip (2.3.2) - sawyer (0.9.2) - addressable (>= 2.3.5) - faraday (>= 0.17.3, < 3) - selenium-webdriver (4.16.0) - rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) - websocket (~> 1.0) - shellany (0.0.1) - shoulda (4.0.0) - shoulda-context (~> 2.0) - shoulda-matchers (~> 4.0) - shoulda-context (2.0.0) - shoulda-matchers (4.5.1) - activesupport (>= 4.2.0) - simpleidn (0.2.1) - unf (~> 0.1.4) - singleton (0.3.0) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) - spring (4.2.1) - spring-commands-rspec (1.0.4) - spring (>= 0.9.1) - spring-watcher-listen (2.1.0) - listen (>= 2.7, < 4.0) - spring (>= 4) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - sprockets (>= 3.0.0) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - text (1.3.1) - thor (1.4.0) - timeout (0.4.3) - tomparse (0.4.2) - turbo-rails (2.0.5) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.5.0) - uniform_notifier (1.16.0) - uri (0.13.2) - uri_template (0.7.0) - validate_url (1.0.15) - activemodel (>= 3.0.0) - public_suffix - version_gem (1.1.3) - warden (1.2.9) - rack (>= 2.0.9) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects - webmock (3.23.1) - addressable (>= 2.8.0) - crack (>= 0.3.2) - hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.10) - websocket-driver (0.7.6) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - wicked_pdf (2.8.2) - activesupport - ostruct - wkhtmltopdf-binary (0.12.6.10) - xpath (3.2.0) - nokogiri (~> 1.8) - yard (0.9.37) - yard-tomdoc (0.7.1) - tomparse (>= 0.4.0) - yard - zeitwerk (2.6.18) - -PLATFORMS - arm64-darwin-21 - arm64-darwin-22 - x86_64-linux - x86_64-linux-musl - -DEPENDENCIES - activerecord-nulldb-adapter - activerecord_json_validator - annotate - annotate_gem - api-pagination - autoprefixer-rails - better_errors - binding_of_caller - bootsnap - brakeman - bullet - bundle-audit - byebug - capybara - contact_us - cssbundling-rails - danger - database_cleaner - devise - devise_invitable - dotenv-rails - dragonfly - dragonfly-s3_data_store - factory_bot_rails - faker - flag_shih_tzu - fuubar - guard - htmltoword - httparty - jbuilder - jsbundling-rails - jwt - kaminari - ledermann-rails-settings - listen - mail (= 2.8.1) - mimemagic - mocha - mysql2 - omniauth - omniauth-orcid - omniauth-rails_csrf_protection - omniauth-shibboleth - omniauth_openid_connect - parallel - pg - progress_bar - puma - pundit - pundit-matchers - rack-attack (~> 6.6, >= 6.6.1) - rack-mini-profiler - rails (~> 6.1) - rails-controller-testing - recaptcha - rollbar - rspec-collection_matchers - rspec-rails - rubocop (= 1.57.1) - rubocop-i18n - rubocop-performance - selenium-webdriver - shoulda - spring - spring-commands-rspec - spring-watcher-listen - text - translation! - turbo-rails - web-console - webmock - wicked_pdf - wkhtmltopdf-binary - yard - yard-tomdoc - -RUBY VERSION - ruby 3.1.4p223 - -BUNDLED WITH - 2.4.15 +GIT + remote: https://github.com/ualbertalib/translation_io_rails + revision: f60a5427372b51348eb218755e275f0f34d19746 + specs: + translation (1.22) + gettext (~> 3.2, >= 3.2.5) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (>= 2.7.1) + actionmailer (6.1.7.9) + actionpack (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.1.7.9) + actionview (= 6.1.7.9) + activesupport (= 6.1.7.9) + rack (~> 2.0, >= 2.0.9) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.7.9) + actionpack (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + nokogiri (>= 1.8.5) + actionview (6.1.7.9) + activesupport (= 6.1.7.9) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.1.7.9) + activesupport (= 6.1.7.9) + globalid (>= 0.3.6) + activemodel (6.1.7.9) + activesupport (= 6.1.7.9) + activerecord (6.1.7.9) + activemodel (= 6.1.7.9) + activesupport (= 6.1.7.9) + activerecord-nulldb-adapter (1.1.1) + activerecord (>= 6.0, < 8.1) + activerecord_json_validator (2.1.5) + activerecord (>= 4.2.0, < 8) + json_schemer (~> 0.2.18) + activestorage (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activesupport (= 6.1.7.9) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (6.1.7.9) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) + annotate_gem (0.0.14) + bundler (>= 1.1) + api-pagination (5.0.0) + ast (2.4.2) + attr_required (1.0.2) + autoprefixer-rails (10.4.19.0) + execjs (~> 2) + base64 (0.1.2) + bcrypt (3.1.20) + better_errors (2.10.1) + erubi (>= 1.0.0) + rack (>= 0.9.0) + rouge (>= 1.0.0) + bigdecimal (3.3.1) + bindata (2.5.0) + bindex (0.8.1) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + bootsnap (1.18.3) + msgpack (~> 1.2) + brakeman (7.0.0) + racc + builder (3.3.0) + bullet (7.1.6) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) + bundle-audit (0.1.0) + bundler-audit + bundler-audit (0.9.1) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + byebug (11.1.3) + capybara (3.39.2) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + coderay (1.1.3) + colored2 (3.1.2) + concurrent-ruby (1.3.4) + contact_us (1.2.0) + rails (>= 4.2.0) + cork (0.3.0) + colored2 (~> 3.1) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + cssbundling-rails (1.4.1) + railties (>= 6.0.0) + csv (3.3.5) + danger (9.4.3) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 3.0) + faraday-http-cache (~> 2.0) + git (~> 1.13) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + no_proxy_fix + octokit (>= 4.0) + terminal-table (>= 1, < 4) + database_cleaner (2.0.2) + database_cleaner-active_record (>= 2, < 3) + database_cleaner-active_record (2.1.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + date (3.4.1) + debug_inspector (1.2.0) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + devise_invitable (2.0.9) + actionmailer (>= 5.0) + devise (>= 4.6) + diff-lcs (1.5.1) + doorkeeper (5.8.2) + railties (>= 5) + dotenv (2.8.1) + dotenv-rails (2.8.1) + dotenv (= 2.8.1) + railties (>= 3.2) + dragonfly (1.4.1) + addressable (~> 2.3) + multi_json (~> 1.0) + ostruct (~> 0.6.1) + rack (>= 1.3) + dragonfly-s3_data_store (1.3.0) + dragonfly (~> 1.0) + fog-aws + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) + email_validator (2.2.4) + activemodel + erubi (1.13.1) + excon (0.104.0) + execjs (2.10.0) + factory_bot (6.2.1) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) + faker (3.4.1) + i18n (>= 1.8.11, < 2) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-http-cache (2.5.1) + faraday (>= 0.8) + faraday-net_http (3.1.0) + net-http + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + flag_shih_tzu (0.3.23) + fog-aws (3.21.0) + fog-core (~> 2.1) + fog-json (~> 1.1) + fog-xml (~> 0.1) + fog-core (2.3.0) + builder + excon (~> 0.71) + formatador (>= 0.2, < 2.0) + mime-types + fog-json (1.2.0) + fog-core + multi_json (~> 1.10) + fog-xml (0.1.4) + fog-core + nokogiri (>= 1.5.11, < 2.0.0) + formatador (1.1.0) + forwardable (1.3.3) + fuubar (2.5.1) + rspec-core (~> 3.0) + ruby-progressbar (~> 1.4) + gettext (3.5.1) + erubi + locale (>= 2.0.5) + prime + racc + text (>= 1.3.0) + git (1.19.1) + addressable (~> 2.8) + rchardet (~> 1.8) + globalid (1.2.1) + activesupport (>= 6.1) + guard (2.19.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + logger (~> 1.6) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + ostruct (~> 0.6) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + hana (1.3.7) + hashdiff (1.1.0) + hashie (5.0.0) + highline (3.1.2) + reline + htmltoword (1.1.1) + actionpack + nokogiri + rubyzip (>= 1.0) + httparty (0.24.2) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + io-console (0.8.0) + jbuilder (2.12.0) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + jsbundling-rails (1.3.0) + railties (>= 6.0.0) + json (2.7.2) + json-jwt (1.16.6) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects + json_schemer (0.2.25) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + uri_template (~> 0.7) + jwt (2.10.1) + base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.3) + ledermann-rails-settings (2.6.2) + activerecord (>= 6.1) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + locale (2.1.4) + logger (1.7.0) + loofah (2.24.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + lumberjack (1.2.10) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.2) + method_source (1.1.0) + mime-types (3.5.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2023.1003) + mimemagic (0.4.3) + nokogiri (~> 1) + rake + mini_mime (1.1.5) + minitest (5.25.4) + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) + msgpack (1.7.2) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + mysql2 (0.5.6) + nap (1.1.0) + nenv (0.3.0) + net-http (0.4.1) + uri + net-imap (0.4.20) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.0) + net-protocol + nio4r (2.7.4) + no_proxy_fix (0.1.2) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-musl) + racc (~> 1.4) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) + octokit (8.1.0) + base64 + faraday (>= 1, < 3) + sawyer (~> 0.9) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-orcid (2.1.1) + omniauth-oauth2 (~> 1.3) + ruby_dig (~> 0.0.2) + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) + omniauth-shibboleth (1.3.0) + omniauth (>= 1.0.0) + omniauth_openid_connect (0.7.1) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + open4 (1.3.4) + openid_connect (2.3.0) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) + options (2.3.2) + orm_adapter (0.5.0) + ostruct (0.6.1) + parallel (1.26.3) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + pg (1.5.9) + prime (0.1.4) + forwardable + singleton + progress_bar (1.3.4) + highline (>= 1.6) + options (~> 2.3.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (6.0.1) + puma (6.6.0) + nio4r (~> 2.0) + pundit (2.3.2) + activesupport (>= 3.0.0) + pundit-matchers (4.0.0) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + racc (1.8.1) + rack (2.2.21) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-mini-profiler (3.3.1) + rack (>= 1.2.0) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-test (2.2.0) + rack (>= 1.3) + rails (6.1.7.9) + actioncable (= 6.1.7.9) + actionmailbox (= 6.1.7.9) + actionmailer (= 6.1.7.9) + actionpack (= 6.1.7.9) + actiontext (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activemodel (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + bundler (>= 1.15.0) + railties (= 6.1.7.9) + sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + method_source + rake (>= 12.2) + thor (~> 1.0) + rainbow (3.1.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rchardet (1.8.0) + recaptcha (5.18.0) + regexp_parser (2.8.2) + reline (0.6.1) + io-console (~> 0.5) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) + rexml (3.4.2) + rollbar (3.5.2) + rouge (4.1.3) + rspec-collection_matchers (1.2.1) + rspec-expectations (>= 2.99.0.beta1) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.2) + rubocop (1.57.1) + base64 (~> 0.1.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.4) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-i18n (3.0.0) + rubocop (~> 1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + ruby_dig (0.0.2) + rubyzip (2.3.2) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + selenium-webdriver (4.16.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + shellany (0.0.1) + shoulda (4.0.0) + shoulda-context (~> 2.0) + shoulda-matchers (~> 4.0) + shoulda-context (2.0.0) + shoulda-matchers (4.5.1) + activesupport (>= 4.2.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + singleton (0.3.0) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) + spring (4.2.1) + spring-commands-rspec (1.0.4) + spring (>= 0.9.1) + spring-watcher-listen (2.1.0) + listen (>= 2.7, < 4.0) + spring (>= 4) + sprockets (4.2.1) + concurrent-ruby (~> 1.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + text (1.3.1) + thor (1.4.0) + timeout (0.4.3) + tomparse (0.4.2) + turbo-rails (2.0.5) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (2.5.0) + uniform_notifier (1.16.0) + uri (0.13.2) + uri_template (0.7.0) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix + version_gem (1.1.3) + warden (1.2.9) + rack (>= 2.0.9) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket (1.2.10) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + wicked_pdf (2.8.2) + activesupport + ostruct + wkhtmltopdf-binary (0.12.6.10) + xpath (3.2.0) + nokogiri (~> 1.8) + yard (0.9.37) + yard-tomdoc (0.7.1) + tomparse (>= 0.4.0) + yard + zeitwerk (2.6.18) + +PLATFORMS + arm64-darwin-21 + arm64-darwin-22 + x86_64-linux + x86_64-linux-musl + +DEPENDENCIES + activerecord-nulldb-adapter + activerecord_json_validator + annotate + annotate_gem + api-pagination + autoprefixer-rails + better_errors + binding_of_caller + bootsnap + brakeman + bullet + bundle-audit + byebug + capybara + contact_us + cssbundling-rails + danger + database_cleaner + devise + devise_invitable + doorkeeper + dotenv-rails + dragonfly + dragonfly-s3_data_store + factory_bot_rails + faker + flag_shih_tzu + fuubar + guard + htmltoword + httparty + jbuilder + jsbundling-rails + jwt + kaminari + ledermann-rails-settings + listen + mail (= 2.8.1) + mimemagic + mocha + mysql2 + omniauth + omniauth-orcid + omniauth-rails_csrf_protection + omniauth-shibboleth + omniauth_openid_connect + parallel + pg + progress_bar + puma + pundit + pundit-matchers + rack-attack (~> 6.6, >= 6.6.1) + rack-mini-profiler + rails (~> 6.1) + rails-controller-testing + recaptcha + rollbar + rspec-collection_matchers + rspec-rails + rubocop (= 1.57.1) + rubocop-i18n + rubocop-performance + selenium-webdriver + shoulda + spring + spring-commands-rspec + spring-watcher-listen + text + translation! + turbo-rails + web-console + webmock + wicked_pdf + wkhtmltopdf-binary + yard + yard-tomdoc + +RUBY VERSION + ruby 3.1.4p223 + +BUNDLED WITH + 2.4.15 diff --git a/Rakefile b/Rakefile index 42f9525d64..16a081e804 100755 --- a/Rakefile +++ b/Rakefile @@ -13,5 +13,6 @@ require_relative 'config/application' DMPRoadmap::Application.load_tasks +Doorkeeper::Rake.load_tasks task default: :test diff --git a/app/assets/images/available.png b/app/assets/images/available.png new file mode 100644 index 0000000000..24c044dc41 Binary files /dev/null and b/app/assets/images/available.png differ diff --git a/app/assets/images/collaborative.png b/app/assets/images/collaborative.png new file mode 100644 index 0000000000..b0b6c71e56 Binary files /dev/null and b/app/assets/images/collaborative.png differ diff --git a/app/assets/images/lifecycle.png b/app/assets/images/lifecycle.png new file mode 100644 index 0000000000..160519d756 Binary files /dev/null and b/app/assets/images/lifecycle.png differ diff --git a/app/assets/stylesheets/dmp-assistant/blocks/_index.scss b/app/assets/stylesheets/dmp-assistant/blocks/_index.scss index 6fd3e7693b..fa638ff2eb 100644 --- a/app/assets/stylesheets/dmp-assistant/blocks/_index.scss +++ b/app/assets/stylesheets/dmp-assistant/blocks/_index.scss @@ -1,2 +1,3 @@ @use 'password_checklist'; -@use 'template_dropdown_menu'; \ No newline at end of file +@use 'template_dropdown_menu'; +@use 'landing'; \ No newline at end of file diff --git a/app/assets/stylesheets/dmp-assistant/blocks/_landing.scss b/app/assets/stylesheets/dmp-assistant/blocks/_landing.scss new file mode 100644 index 0000000000..3218010a6a --- /dev/null +++ b/app/assets/stylesheets/dmp-assistant/blocks/_landing.scss @@ -0,0 +1,56 @@ +@use '../variables/colours' as *; + +.custom-accordion { + + summary { + cursor: pointer; + padding: 16px 20px 16px 32px; + font-weight: 600; + font-size: 18px; + color: $color-alliance-digital-grey; + position: relative; + + &::before { + content: "▸"; + position: absolute; + left: 16px; + transition: transform 0.2s ease; + } + } + + details[open] summary::before { + transform: rotate(90deg); + } + + details > *:not(summary) { + padding: 0 20px 20px 40px; + background: $color-white; + } +} + +.welcome-features { + margin: 30px 0; + padding: 30px; + display: flex; + justify-content: space-between; + gap: 40px; + text-align: center; + + .feature { + flex: 1; + + .feature-icon { + margin-bottom: 15px; + + img { + height: 70px; + width: auto; + } + } + + p { + font-weight: 600; + color: $color-alliance-digital-grey; + } + } +} \ No newline at end of file diff --git a/app/controllers/api/v2/base_api_controller.rb b/app/controllers/api/v2/base_api_controller.rb new file mode 100644 index 0000000000..fa4aad2b79 --- /dev/null +++ b/app/controllers/api/v2/base_api_controller.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Api + module V2 + class BaseApiController < ApplicationController # rubocop:todo Style/Documentation + # skipping the standard rails authenticity tokens passed in the UI + skip_before_action :verify_authenticity_token + + # call doorkeeper to authorize the request + before_action :doorkeeper_authorize!, except: %i[heartbeat] + # get details of server (e.g. DMPonline) and client app + before_action :base_response_content + + before_action :log_access + + before_action :require_read_scope, except: %i[heartbeat me] + # controller can respond to json format requests + respond_to :json + + # set up pages in response + before_action :pagination_params, except: %i[heartbeat] + + rescue_from StandardError, with: :handle_exception + + # GET /api/v2/heartbeat + def heartbeat + render '/api/v2/heartbeat' + end + + # GET /me.json - recommended for doorkeeper gem + def me + render json: @resource_owner.slice(:firstname, :surname, :email).merge( + organisation: @resource_owner.org.name, + language: @resource_owner.language&.name + ) + end + + private + + # define instance variable json and associated getter and setter methods + attr_accessor :json + + def base_response_content + @application = ApplicationService.application_name + @client = doorkeeper_token&.application + @caller = @client&.name || request.remote_ip + return unless doorkeeper_token&.resource_owner_id + + @resource_owner = User.find(doorkeeper_token.resource_owner_id) + end + + def log_access + if @client.present? + Rails.logger.info "Client (OAuth) application name: #{@client.name}" + Rails.logger.info "Client (OAuth) application uid: #{@client.uid}" + end + Rails.logger.info "Resource owner id: #{@resource_owner.id}" if @resource_owner + end + + def handle_exception(exception) + if exception.is_a?(Pundit::NotAuthorizedError) + handle_client_not_authorized + else + handle_internal_server_error(exception) + end + end + + def handle_internal_server_error(exception) + # log server errors + Rails.logger.error "Exception message: #{exception.message}" + + # inform client of server error + message = _('There was a problem in the server.') + @payload = { message: [message] } + render '/api/v2/error', status: :internal_server_error + end + + def handle_client_not_authorized + message = _('The client is not authorized to perform this action.') + @payload = { message: [message] } + render '/api/v2/error', status: :forbidden + end + + # retrieve the requested pagination params or use defaults + # only allow 100 per page as the max + def pagination_params + max_per_page = Rails.configuration.x.application.api_max_page_size + @page = params.fetch('page', 1).to_i + @per_page = params.fetch('per_page', max_per_page).to_i + @per_page = max_per_page if @per_page > max_per_page + end + + def paginate_response(results:) + results = results.page(@page).per(@per_page) + @total_items = results.total_count + results + end + + def require_read_scope + raise Pundit::NotAuthorizedError unless doorkeeper_token.scopes.include?('read') + end + end + end +end diff --git a/app/controllers/api/v2/internal_user_access_tokens_controller.rb b/app/controllers/api/v2/internal_user_access_tokens_controller.rb new file mode 100644 index 0000000000..6cb19454ec --- /dev/null +++ b/app/controllers/api/v2/internal_user_access_tokens_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V2 + # Controller for managing the current user's internal V2 API access token. + # Provides token rotation for authenticated internal users. + # See Api::V2::InternalUserAccessTokenService for token implementation details. + class InternalUserAccessTokensController < ApplicationController + # POST "/api/v2/internal_user_access_token" + def create + authorize current_user, :internal_user_v2_access_token? + @v2_token = Api::V2::InternalUserAccessTokenService.rotate!(current_user) + @success = true + respond_to do |format| + format.js { render 'users/refresh_token' } + end + end + end + end +end diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb new file mode 100644 index 0000000000..ba4c507bb6 --- /dev/null +++ b/app/controllers/api/v2/plans_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Api + module V2 + class PlansController < BaseApiController # rubocop:todo Style/Documentation + respond_to :json + before_action :set_complete_param, only: %i[show index] + + # GET /api/v2/plans/:id + def show + @plan = plans_scope.find_by(id: params[:id]) + + plans_policy = PlansPolicy.new(@resource_owner, @plan) + raise Pundit::NotAuthorizedError unless plans_policy.show? + + @items = [@plan] + render '/api/v2/plans/index', status: :ok + end + + # GET /api/v2/plans + def index + @plans = plans_scope + @items = paginate_response(results: @plans) + render '/api/v2/plans/index', status: :ok + end + + private + + # GET /api/v2/plans?complete=true and /api/v2/plans/:id?complete=true + def set_complete_param + @complete = params[:complete].to_s.casecmp('true').zero? + end + + def plans_scope + scope = PlansPolicy::Scope.new(@resource_owner).resolve + @complete ? scope.includes(answers: { question: :section }) : scope + end + end + end +end diff --git a/app/controllers/api/v2/templates_controller.rb b/app/controllers/api/v2/templates_controller.rb new file mode 100644 index 0000000000..2b9a4bf4bb --- /dev/null +++ b/app/controllers/api/v2/templates_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api + module V2 + # provides a list of templates for API V2 + class TemplatesController < BaseApiController + respond_to :json + + # GET /api/v2/templates + def index + templates = Api::V2::TemplatesPolicy::Scope.new(@resource_owner).resolve + @items = paginate_response(results: templates) + render '/api/v2/templates/index', status: :ok + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cc3c422974..65b1ca6400 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -61,28 +61,25 @@ def store_location end end - # rubocop:disable Metrics/AbcSize def after_sign_in_path_for(_resource) - referer_path = URI(request.referer).path unless request.referer.nil? - if from_external_domain? || referer_path.eql?(new_user_session_path) || - referer_path.eql?(new_user_registration_path) || - referer_path.nil? - root_path - else - request.referer - end + after_auth_path(disallowed_paths: [new_user_session_path, new_user_registration_path]) end - # rubocop:enable Metrics/AbcSize def after_sign_up_path_for(_resource) - referer_path = URI(request.referer).path unless request.referer.nil? - if from_external_domain? || - referer_path.eql?(new_user_session_path) || - referer_path.nil? - root_path - else - request.referer - end + after_auth_path(disallowed_paths: [new_user_session_path]) + end + + def after_auth_path(disallowed_paths:) + # ensure oauth2 authorization flow is not interrupted + # TODO: Unless nil, should stored_location_for(resource) always be returned? + return stored_location_for(:user) if user_is_in_oauth_flow? + + return root_path if request.referer.nil? || from_external_domain? + + referer_path = URI(request.referer).path + return root_path if disallowed_paths.include?(referer_path) + + request.referer end def after_sign_in_error_path_for(_resource) @@ -194,4 +191,8 @@ def render_respond_to_format_with_error_message(msg, url_or_path, http_status, e end end end + + def user_is_in_oauth_flow? + session[:user_return_to]&.start_with?(oauth_authorization_path) + end end diff --git a/app/controllers/concerns/plan_permitted_params.rb b/app/controllers/concerns/plan_permitted_params.rb new file mode 100644 index 0000000000..90363f9de8 --- /dev/null +++ b/app/controllers/concerns/plan_permitted_params.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module PlanPermittedParams # rubocop:todo Metrics/ModuleLength, Style/Documentation + extend ActiveSupport::Concern + + def plan_permitted_params + [ + :created, + :title, + :description, + :language, + :ethical_issues_exist, + :ethical_issues_description, + :ethical_issues_report, + { dmp_ids: identifier_permitted_params }, + { contact: contributor_permitted_params }, + { contributors: contributor_permitted_params }, + { costs: cost_permitted_params }, + { project: project_permitted_params }, + { datasets: dataset_permitted_params } + ] + end + + def identifier_permitted_params + %i[ + type + identifier + ] + end + + def contributor_permitted_params + [ + :firstname, + :surname, + :mbox, + :role, + { affiliations: affiliation_permitted_params }, + { contributor_ids: identifier_permitted_params } + ] + end + + def affiliation_permitted_params + [ + :name, + :abbreviation, + { affiliation_ids: identifier_permitted_params } + ] + end + + def cost_permitted_params + %i[ + title + description + value + currency_code + ] + end + + def project_permitted_params + [ + :title, + :description, + :start_on, + :end_on, + { funding: funding_permitted_params } + ] + end + + def funding_permitted_params + [ + :name, + :funding_status, + { funder_ids: identifier_permitted_params }, + { grant_ids: identifier_permitted_params } + ] + end + + def dataset_permitted_params + [ + :title, + :doi_url, + :description, + :type, + :issued, + :language, + :personal_data, + :sensitive_data, + :keywords, + :data_quality_assurance, + :preservation_statement, + { dataset_ids: identifier_permitted_params }, + { metadata: metadatum_permitted_params }, + { security_and_privacy_statements: security_and_privacy_statement_permitted_params }, + { technical_resources: technical_resource_permitted_params }, + { distributions: distribution_permitted_params } + ] + end + + def metadatum_permitted_params + [ + :description, + :language, + { identifier: identifier_permitted_params } + ] + end + + def security_and_privacy_statement_permitted_params + %i[ + title + description + ] + end + + def technical_resource_permitted_params + [ + :description, + { identifier: identifier_permitted_params } + ] + end + + def distribution_permitted_params + [ + :title, + :description, + :format, + :byte_size, + :access_url, + :download_url, + :data_access, + :available_until, + { licenses: license_permitted_params }, + { host: host_permitted_params } + ] + end + + def license_permitted_params + %i[ + license_ref + start_date + ] + end + + def host_permitted_params + [ + :title, + :description, + :supports_versioning, + :backup_type, + :backup_frequency, + :storage_type, + :availability, + :geo_location, + :certified_with, + :pid_system, + { host_ids: identifier_permitted_params } + ] + end +end diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb index 0ec8174d42..043d15d113 100644 --- a/app/controllers/plan_exports_controller.rb +++ b/app/controllers/plan_exports_controller.rb @@ -114,8 +114,11 @@ def show_pdf end def show_json - json = render_to_string(partial: '/api/v1/plans/show', locals: { plan: @plan }) - render json: "{\"dmp\":#{json}}" + @complete = true + @plan = Plan.for_api_v2(current_user.id) + .find_by(id: @plan.id) + @items = [@plan] + render '/api/v2/plans/index', formats: :json end def file_name diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5cc1633bc9..bfaa2973dd 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -53,24 +53,6 @@ def current_locale_logo end end - def how_to_manage_your_data_path - if I18n.locale == :'fr-CA' - 'https://portagenetwork.ca/fr/outils-et-ressources/assistant-pgd/comment-gerer-vos-donnees/' - else - # Handling :'en-CA' locale - 'https://portagenetwork.ca/tools-and-resources/dmp-assistant/how-to-manage-your-data/' - end - end - - def contacts_at_your_instutution_path - if I18n.locale == :'fr-CA' - 'https://alliancecan.ca/fr/services/gestion-des-donnees-de-recherche/apprentissage-et-ressources/personnes-ressources-dans-les-etablissements' - else - # Handling :'en-CA' locale - 'https://alliancecan.ca/en/services/research-data-management/learning-and-training/institutional-contacts' - end - end - def training_resources_path if I18n.locale == :'fr-CA' 'https://alliancecan.ca/fr/services/gestion-des-donnees-de-recherche/apprentissage-et-ressources/ressources-de-formation' diff --git a/app/javascript/application.js b/app/javascript/application.js index 1345f66a4d..8c22536928 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -24,6 +24,7 @@ import 'bootstrap-select'; // Utilities import './src/utils/accordion'; import './src/utils/autoComplete'; +import './src/utils/copyToken.js'; import './src/utils/externalLink'; import './src/utils/modalSearch'; import './src/utils/outOfFocus'; diff --git a/app/javascript/src/utils/copyToken.js b/app/javascript/src/utils/copyToken.js new file mode 100644 index 0000000000..3da9c90fb7 --- /dev/null +++ b/app/javascript/src/utils/copyToken.js @@ -0,0 +1,35 @@ +const initCopyToken = () => { + document.addEventListener('click', function (e) { + const button = e.target.closest('#copy-token-btn'); + if (!button) return; + + e.preventDefault(); + + // Prevent spam clicking + if (button.disabled) return; + + const tokenInput = document.getElementById('api-token-val'); + if (!tokenInput) return; + + const originalHTML = button.innerHTML; + + // Disable immediately + button.disabled = true; + + navigator.clipboard.writeText(tokenInput.value).then(() => { + // Replace button contents with check icon + button.innerHTML = ''; + + // Restore after 2s + setTimeout(() => { + button.innerHTML = originalHTML; + button.disabled = false; + }, 2000); + }).catch(() => { + button.disabled = false; + alert('Failed to copy token'); + }); + }); +}; + +initCopyToken(); diff --git a/app/models/plan.rb b/app/models/plan.rb index 772e5c7ed5..51cdb3ae01 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -207,6 +207,39 @@ class Plan < ApplicationRecord includes(:phases, :sections, :questions, template: [:org]).find(id) } + # Eager loads all associations needed for API v2 serialization, + # and restricts to plans where the user_id has an active role. + scope :for_api_v2, lambda { |user_id| + joins(:roles) + .includes( + :research_outputs, + :template, + { identifiers: :identifier_scheme }, + funder: { identifiers: :identifier_scheme }, + contributors: [ + { identifiers: :identifier_scheme }, + { org: { identifiers: :identifier_scheme } } + ], + roles: [ + user: [ + :language, + { identifiers: :identifier_scheme }, + { org: { identifiers: :identifier_scheme } } + ] + ], + # plan.org is only executed when `plan.funder.present? || plan.grant_id.present? == true` + # - (see `app/views/api/v2/plans/_project.json.jbuilder`) + # Thus, the following line avoids N+1 queries in some cases, + # but performs unnecessary eager loading in others + org: [ + :region, + { identifiers: :identifier_scheme } + ] + ) + .where(roles: { user_id: user_id, active: true }) + .distinct + } + ## # Settings for the template has_settings :export, class_name: 'Settings::Template' do |s| diff --git a/app/models/template.rb b/app/models/template.rb index 3dd54abb02..d32416d453 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -215,6 +215,16 @@ class Template < ApplicationRecord term: "%#{term}%") } + scope :for_api_v2, lambda { |org_id| + org_templates = organisationally_visible.where(org_id: org_id) + public_templates = publicly_visible.where(customization_of: nil) + includes(org: { identifiers: :identifier_scheme }) + .joins(:org) + .published + .merge(org_templates.or(public_templates)) + .order(:title) + } + # defines the export setting for a template object has_settings :export, class_name: 'Settings::Template' do |s| s.key :export, defaults: Settings::Template::DEFAULT_SETTINGS diff --git a/app/models/user.rb b/app/models/user.rb index fe9579eb79..fe149e75c9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,6 +113,12 @@ class User < ApplicationRecord has_and_belongs_to_many :notifications, dependent: :destroy, join_table: 'notification_acknowledgements' + has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', foreign_key: :resource_owner_id, + dependent: :delete_all + + has_many :access_tokens, class_name: 'Doorkeeper::AccessToken', foreign_key: :resource_owner_id, + dependent: :delete_all + # =============== # = Validations = # =============== diff --git a/app/policies/api/v2/plans_policy.rb b/app/policies/api/v2/plans_policy.rb new file mode 100644 index 0000000000..16fc07edd4 --- /dev/null +++ b/app/policies/api/v2/plans_policy.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Api + module V2 + # Security rules for API V2 Plan endpoints + class PlansPolicy < ApplicationPolicy + # overriding the initializer due to resource owner / user + # not needing to be logged in for client app to make requests + def initialize(resource_owner, plan = nil) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + @plan = plan + end + + def show? + # The show action uses the resolve method, so only a presence check + # is needed here (see the resolve method comment for more). + @plan.present? + end + + class Scope < Scope # rubocop:todo Style/Documentation + def initialize(resource_owner) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + end + + # Eager loads all associations needed for API v2 serialization, + # and restricts to plans where the user_id has an active role. + # - (i.e. .where(roles: { user_id: @resource_owner.id, active: true })) + def resolve + Plan.for_api_v2(@resource_owner.id) + end + end + end + end +end diff --git a/app/policies/api/v2/templates_policy.rb b/app/policies/api/v2/templates_policy.rb new file mode 100644 index 0000000000..c12a14737b --- /dev/null +++ b/app/policies/api/v2/templates_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Api + module V2 + class TemplatesPolicy < ApplicationPolicy + class Scope < Scope # rubocop:todo Style/Documentation + def initialize(resource_owner) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + end + + def resolve + # get the templates + Template.for_api_v2(@resource_owner.org&.id) + end + end + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 978c40d412..dd4527d941 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -55,6 +55,12 @@ def refresh_token? (@user.can_org_admin? && @user.can_use_api?) end + # Safe: only allows the signed-in user to generate/rotate their own token. + # These are first-party, user-scoped tokens and do not affect other users. + def internal_user_v2_access_token? + true + end + def merge? @user.can_super_admin? end diff --git a/app/presenters/api/v2/api_presenter.rb b/app/presenters/api/v2/api_presenter.rb new file mode 100644 index 0000000000..1ad7290ab6 --- /dev/null +++ b/app/presenters/api/v2/api_presenter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V2 + # Generic helper methods for API V2 + class ApiPresenter + class << self + def boolean_to_yes_no_unknown(value:) + return 'unknown' unless value.present? + + value ? 'yes' : 'no' + end + end + end + end +end diff --git a/app/presenters/api/v2/contributor_presenter.rb b/app/presenters/api/v2/contributor_presenter.rb new file mode 100644 index 0000000000..77232c2838 --- /dev/null +++ b/app/presenters/api/v2/contributor_presenter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 contributors views + class ContributorPresenter + class << self + # Convert the specified role into a CRediT Taxonomy URL + def role_as_uri(role:) + return nil unless role.present? + return 'other' if role.to_s.casecmp('other').zero? + + "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.downcase.tr('_', '-')}" + end + + def contributor_id(identifiers:) + identifiers.find { |id| id.identifier_scheme.name == 'orcid' } + end + end + end + end +end diff --git a/app/presenters/api/v2/funding_presenter.rb b/app/presenters/api/v2/funding_presenter.rb new file mode 100644 index 0000000000..c878daadb6 --- /dev/null +++ b/app/presenters/api/v2/funding_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 funding section + class FundingPresenter + class << self + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def status(plan:) + return 'planned' unless plan.present? + + case plan.funding_status + when 'funded' + 'granted' + when 'denied' + 'rejected' + else + 'planned' + end + end + end + end + end +end diff --git a/app/presenters/api/v2/language_presenter.rb b/app/presenters/api/v2/language_presenter.rb new file mode 100644 index 0000000000..2be21c7e38 --- /dev/null +++ b/app/presenters/api/v2/language_presenter.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 language values + class LanguagePresenter + class << self + LANGUAGE_MAP = { + aa: 'aar', ab: 'abk', af: 'afr', ak: 'aka', am: 'amh', ar: 'ara', an: 'arg', + as: 'asm', av: 'ava', ae: 'ave', ay: 'aym', az: 'aze', + + ba: 'bak', bm: 'bam', be: 'bel', bn: 'ben', bh: 'bih', bi: 'bis', bo: 'tib', + bs: 'bos', br: 'bre', bg: 'bul', + + ca: 'cat', cs: 'cze', ch: 'cha', ce: 'che', cu: 'chu', cv: 'chv', co: 'cos', + cr: 'cre', cy: 'wel', + + da: 'dan', de: 'deu', dv: 'div', dz: 'dzo', + + el: 'gre', en: 'eng', eo: 'epo', es: 'spa', et: 'est', eu: 'baq', ee: 'ewe', + + fo: 'fao', fa: 'per', fj: 'fij', fi: 'fin', fr: 'fre', fy: 'fry', ff: 'ful', + + gd: 'gla', ga: 'gle', gl: 'glg', gv: 'glv', gn: 'grn', gu: 'guj', + + ht: 'hat', ha: 'hau', he: 'heb', hz: 'her', hi: 'hin', ho: 'hmo', hr: 'hrv', + hu: 'hun', hy: 'arm', + + ig: 'ibo', io: 'ido', ii: 'iii', iu: 'iku', ie: 'ile', ia: 'ina', id: 'ind', + ik: 'ipk', is: 'ice', it: 'ita', + + jv: 'jav', ja: 'jpn', + + kl: 'kal', kn: 'kan', ks: 'kas', kr: 'kau', kk: 'kaz', km: 'khm', ki: 'kik', + ky: 'kir', kv: 'kom', kg: 'kon', ko: 'kor', kj: 'kua', ku: 'kur', ka: 'geo', + kw: 'cor', + + lo: 'lao', la: 'lat', lv: 'lav', li: 'lim', ln: 'lin', lt: 'lit', lb: 'ltz', + lu: 'lub', lg: 'lug', + + mk: 'mac', mh: 'mah', ml: 'mal', mi: 'mao', mr: 'mar', ms: 'may', mg: 'mlg', + mt: 'mlt', mn: 'mon', my: 'bur', + + na: 'nau', nv: 'nav', nr: 'nbl', nd: 'nde', ng: 'ndo', ne: 'nep', nl: 'dut', + nn: 'nno', nb: 'nob', no: 'nor', ny: 'nya', + + oc: 'oci', oj: 'oji', or: 'ori', om: 'orm', os: 'oss', + + pa: 'pan', pi: 'pli', pl: 'pol', pt: 'por', ps: 'pus', + + qu: 'que', + + rm: 'roh', ro: 'rum', rn: 'run', ru: 'rus', rw: 'kin', + + sg: 'sag', sa: 'san', si: 'sin', sk: 'slo', sl: 'slv', se: 'sme', sm: 'smo', + sn: 'sna', sd: 'snd', so: 'som', st: 'sot', sq: 'alb', sc: 'srd', sr: 'srp', + ss: 'ssw', su: 'sun', sw: 'swa', sv: 'swe', + + ty: 'tah', ta: 'tam', tt: 'tat', te: 'tel', tg: 'tgk', tl: 'tgl', th: 'tha', + ti: 'tir', to: 'ton', tn: 'tsn', ts: 'tso', tk: 'tuk', tr: 'tur', tw: 'twi', + + ug: 'uig', uk: 'ukr', ur: 'urd', uz: 'uzb', + + ve: 'ven', vi: 'vie', vo: 'vol', + + wa: 'wln', wo: 'wol', + + xh: 'xho', + + yi: 'yid', yo: 'yor', + + za: 'zha', zh: 'chi', zu: 'zul' + }.freeze + + # Convert the incoming 2 (e.g. en - ISO 639-1) or 2+region (e.g. en-UK) + # into the 3 character code (e.g. eng - ISO 639-2) + def three_char_code(lang:) + lang ||= LocaleService.default_locale + two_char_code = lang.to_s.split('-').first + LANGUAGE_MAP[two_char_code.to_sym] + end + end + end + end +end diff --git a/app/presenters/api/v2/org_presenter.rb b/app/presenters/api/v2/org_presenter.rb new file mode 100644 index 0000000000..5302acb7a7 --- /dev/null +++ b/app/presenters/api/v2/org_presenter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 affiliation sections + class OrgPresenter + class << self + def affiliation_id(identifiers:) + ident = identifiers.find { |id| id.identifier_scheme&.name == 'ror' } + return ident if ident.present? + + identifiers.find { |id| id.identifier_scheme&.name == 'fundref' } + end + end + end + end +end diff --git a/app/presenters/api/v2/pagination_presenter.rb b/app/presenters/api/v2/pagination_presenter.rb new file mode 100644 index 0000000000..1b8fbc1109 --- /dev/null +++ b/app/presenters/api/v2/pagination_presenter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for genewric API V2 pagination + class PaginationPresenter + def initialize(current_url:, per_page:, total_items:, current_page: 1) + @url = current_url + @per_page = per_page + @total_items = total_items + @page = current_page + end + + def url_without_pagination + return nil unless @url.present? && @url.is_a?(String) + + url = @url.gsub(/per_page=\d+/, '') + .gsub(/page=\d+/, '') + .gsub(/(&)+$/, '').gsub(/\?$/, '') + + (url.include?('?') ? "#{url}&" : "#{url}?") + end + + def prev_page? + total_pages > 1 && @page != 1 + end + + def next_page? + total_pages > 1 && @page < total_pages + end + + def prev_page_link + "#{url_without_pagination}page=#{@page - 1}&per_page=#{@per_page}" + end + + def next_page_link + "#{url_without_pagination}page=#{@page + 1}&per_page=#{@per_page}" + end + + private + + def total_pages + return 1 unless @total_items.present? && @per_page.present? && + @total_items.positive? && @per_page.positive? + + (@total_items.to_f / @per_page).ceil + end + end + end +end diff --git a/app/presenters/api/v2/plan_presenter.rb b/app/presenters/api/v2/plan_presenter.rb new file mode 100644 index 0000000000..8cb7eca6ef --- /dev/null +++ b/app/presenters/api/v2/plan_presenter.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 project / DMP + class PlanPresenter + attr_reader :data_contact, :contributors, :costs, :complete_plan_data + + def initialize(plan:, complete: false) + @contributors = [] + return unless plan.present? + + @plan = plan + + # Use owner or first data_curation role as the data_contact + @data_contact = @plan.owner || @plan.contributors.find(&:data_curation?) + @contributors = @plan.contributors.to_a + + @costs = plan_costs(plan: @plan) + + @complete_plan_data = fetch_all_q_and_a if complete + end + + # Extract the ARK or DOI for the DMP OR use its URL if none exists + def identifier + doi = @plan.identifiers.select do |id| + ::Plan::DMP_ID_TYPES.include?(id.identifier_format) + end + return doi.first if doi.first.present? + + # if no DOI then use the URL for the API's 'show' method + Identifier.new(value: Rails.application.routes.url_helpers.api_v2_plan_url(@plan)) + end + + private + + # Retrieve the answers that have the Budget theme + def plan_costs(plan:) + theme = Theme.where(title: 'Cost').first + return [] unless theme.present? + + # TODO: define a new 'Currency' question type that includes a float field + # any currency type selector (e.g GBP or USD) + answers = plan.answers + .joins(question: :themes) + .where(themes: { id: theme.id }) + .includes(:question) + + answers.map do |answer| + # TODO: Investigate whether question level guidance should be the description + { title: answer.question.text, description: nil, + currency_code: 'usd', value: answer.text } + end + end + + # Fetch all questions and answers from a plan, regardless of theme + def fetch_all_q_and_a + answers = @plan.answers + return [] unless answers.present? + + answers.filter_map do |answer| + q = answer.question + next unless q.present? + + { + id: q.id, + title: "Question #{q.number || q.id}", + section: q.section&.title, + question: q.text.to_s, + answer: answer.text.to_s + } + end + end + end + end +end diff --git a/app/presenters/api/v2/research_output_presenter.rb b/app/presenters/api/v2/research_output_presenter.rb new file mode 100644 index 0000000000..9571259e82 --- /dev/null +++ b/app/presenters/api/v2/research_output_presenter.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper methods for research outputs + class ResearchOutputPresenter + include ActionView::Helpers::SanitizeHelper + attr_reader :dataset_id, :preservation_statement, :security_and_privacy, :license_start_date, + :data_quality_assurance, :distributions, :metadata, :technical_resources + + def initialize(output:) + @research_output = output + return unless output.is_a?(ResearchOutput) + + @plan = output.plan + @dataset_id = identifier + + load_narrative_content + + @license_start_date = determine_license_start_date(output: output) + end + + private + + def identifier + Identifier.new(identifiable: @research_output, value: @research_output.id) + end + + def determine_license_start_date(output:) + return nil unless output.present? + return output.release_date.to_formatted_s(:iso8601) if output.release_date.present? + + output.created_at.to_formatted_s(:iso8601) + end + + def load_narrative_content + @preservation_statement = '' + @security_and_privacy = [] + @data_quality_assurance = '' + + # Disabling rubocop here since a guard clause would make the line too long + # rubocop:disable Style/GuardClause + if Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions + @preservation_statement = fetch_q_and_a_as_single_statement(themes: %w[Preservation]) + end + if Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions + @security_and_privacy = fetch_q_and_a(themes: ['Ethics & privacy', 'Storage & security']) + end + if Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions + @data_quality_assurance = fetch_q_and_a_as_single_statement(themes: ['Data Collection']) + end + # rubocop:enable Style/GuardClause + end + + def fetch_q_and_a_as_single_statement(themes:) + fetch_q_and_a(themes: themes).collect { |item| item[:description] }.join('
') + end + + def fetch_q_and_a(themes:) + return [] unless themes.is_a?(Array) && themes.any? + + answers = answers_for_themes(themes) + + descs_by_theme = build_descriptions_by_theme_hash(answers, themes) + + descs_by_theme.map do |theme, descs| + { title: theme, description: descs } + end + end + + def answers_for_themes(themes) + @plan.answers + .joins(question: :themes) + .where(themes: { title: themes }) + .includes(question: :themes) + .distinct + end + + def build_descriptions_by_theme_hash(answers, themes) + descs_by_theme = Hash.new { |h, k| h[k] = [] } + + answers.each do |answer| + answer.question.themes.each do |theme| + next unless themes.include?(theme.title) + + descs_by_theme[theme.title] << format_q_and_a(answer.question, answer) + end + end + descs_by_theme + end + + def format_q_and_a(question, answer) + "Question: #{sanitize(question.text)}
" \ + "Answer: #{sanitize(answer.text)}" + end + end + end +end diff --git a/app/presenters/api/v2/template_presenter.rb b/app/presenters/api/v2/template_presenter.rb new file mode 100644 index 0000000000..2d32a45d37 --- /dev/null +++ b/app/presenters/api/v2/template_presenter.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 template info + class TemplatePresenter + def initialize(template:) + @template = template + end + + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def title + return @template.title unless @template.customization_of.present? + + "#{@template.title} - with additional questions for #{@template.org.name}" + end + end + end +end diff --git a/app/services/api/v2/conversion_service.rb b/app/services/api/v2/conversion_service.rb new file mode 100644 index 0000000000..4a9a932878 --- /dev/null +++ b/app/services/api/v2/conversion_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper service that translates to/from the RDA common standard + class ConversionService + class << self + # Converts a boolean field to [yes, no, unknown] + def boolean_to_yes_no_unknown(value) + return 'yes' if [true, 1].include?(value) + + return 'no' if [false, 0].include?(value) + + 'unknown' + end + + # Converts a [yes, no, unknown] field to boolean (or nil) + def yes_no_unknown_to_boolean(value) + return true if value&.downcase == 'yes' + + return nil if value.blank? || value&.downcase == 'unknown' + + false + end + + # Converts the context and value into an Identifier with a psuedo + # IdentifierScheme for display in JSON partials. Which will result in: + # { type: 'context', identifier: 'value' } + def to_identifier(context:, value:) + return nil unless value.present? && context.present? + + scheme = IdentifierScheme.new(name: context) + Identifier.new(value: value, identifier_scheme: scheme) + end + end + end + end +end diff --git a/app/services/api/v2/internal_user_access_token_service.rb b/app/services/api/v2/internal_user_access_token_service.rb new file mode 100644 index 0000000000..7eeac14f5b --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Api + module V2 + # Service responsible for user-scoped v2 API access tokens, strictly for + # internal users of this application. + # + # Tokens issued by this service are functionally equivalent to Personal Access + # Tokens (PATs) for first-party usage. They are minted directly for a user + # who is already authenticated in the application, bypassing the standard + # OAuth 2.0 authorization_code redirect and consent flow. + # + # This design is intentional: + # - tokens are internal to this application (first-party) + # - tokens are owned by a single user and scoped accordingly + # - token creation, rotation, and revocation happen entirely within the app UI + # + # Tokens are stored as Doorkeeper::AccessToken records to leverage existing + # scoping, expiry, and revocation mechanisms. + # + # This service does NOT support third-party OAuth clients or delegated consent flows. + class InternalUserAccessTokenService + READ_SCOPE = 'read' + INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name + + class << self + def rotate!(user) + revoke_existing!(user) + + token = Doorkeeper::AccessToken.create!( + application_id: application!.id, + resource_owner_id: user.id, + scopes: READ_SCOPE, + expires_in: 24.hours # Overrides Doorkeeper's `access_token_expires_in` + ) + token.plaintext_token + end + + # Used by views (e.g. devise/registrations/_v2_api_token.html.erb) to safely + # gate token UI if the internal OAuth application is missing. + def application_present? + application! + true + rescue StandardError => e + Rails.logger.error(e.message) + false + end + + private + + def application! + Doorkeeper::Application.find_by(name: INTERNAL_OAUTH_APP_NAME) || + raise( + StandardError, + "Required Doorkeeper application '#{INTERNAL_OAUTH_APP_NAME}' not found. " \ + 'Please ensure the application exists in the database.' + ) + end + + def revoke_existing!(user) + Doorkeeper::AccessToken.revoke_all_for(application!.id, user) + end + end + end + end +end diff --git a/app/services/external_apis/ror_service.rb b/app/services/external_apis/ror_service.rb index 3c646105c9..82e88123ae 100644 --- a/app/services/external_apis/ror_service.rb +++ b/app/services/external_apis/ror_service.rb @@ -77,7 +77,7 @@ def search(term:, filters: []) private - # Queries the ROR API for the sepcified name and page + # Queries the ROR API for the specified name and page def query_ror(term:, page: 1, filters: []) return [] unless term.present? @@ -142,69 +142,126 @@ def parse_results(json:) return results unless json.present? && json.fetch('items', []).any? json['items'].each do |item| - next unless item['id'].present? && item['name'].present? - results << { ror: item['id'].gsub(/^#{landing_page_url}/, ''), name: org_name(item: item), - sort_name: item['name'], - url: item.fetch('links', []).first, + sort_name: sort_name(item: item), + url: org_url(item: item), language: org_language(item: item), fundref: fundref_id(item: item), - abbreviation: item.fetch('acronyms', []).first + abbreviation: org_abbreviation(item: item) } + rescue KeyError, NoMethodError => e + Rails.logger.error( + "Invalid ROR record: #{e.class} - #{e.message}, item: #{item.inspect}" + ) end results end # rubocop:enable Metrics/AbcSize + def ror_display_entry(item:) + item['names'].find { |n| n['types'].include?('ror_display') } + end + + # Extracts the org's display name from `names` to be used as sort_name + # "names": [ + # {"lang": "en", "types": ["ror_display","label"], "value": "Harvard University"}, + # {"lang": "es","types": ["label"], "value": "Universidad de Harvard"} + # ] + def sort_name(item:) + ror_display_entry(item: item).fetch('value') + end + + # Returns the website link value + # "links": [ + # { "type": "website", "value": "https://example.edu" }, + # { "type": "Wikipedia", "value": "https://en.wikipedia.org/wiki/Example_University" } + # ] + def org_url(item:) + item['links']&.find { |l| l['type'] == 'website' }&.fetch('value') + end + + # Returns the country name from locations + # "locations" : [ { "geonames_details" : { "country_name" : "Germany",}, "geonames_id" : 2928810 } ] + def org_country(item:) + location = item['locations']&.find { |l| l['geonames_details'].present? } + location&.dig('geonames_details', 'country_name').to_s + end + + # Extract acronym/abbreviation from names + # "names" : [ { "value" : "UC", "types": ["acronym"], "lang" : "en" } ] + def org_abbreviation(item:) + item['names'].find { |n| n['types'].include?('acronym') }&.fetch('value') + end + # Org names are not unique, so include the Org URL if available or # the country. For example: # "Example College (example.edu)" # "Example College (Brazil)" def org_name(item:) - return '' unless item.present? && item['name'].present? + name = sort_name(item: item) - country = item.fetch('country', {}).fetch('country_name', '') + country = org_country(item: item) website = org_website(item: item) + # If no website or country then just return the name - return item['name'] unless website.present? || country.present? + return name unless website.present? || country.present? # Otherwise return the contextualized name - "#{item['name']} (#{website || country})" + "#{name} (#{website || country})" end - # Extracts the org's ISO639 if available + # Extracts the org's language + # { + # "id": "https://ror.org/012345678", + # "names": [ + # { "value": "Université de Montréal", "types": ["ror_display"], "lang": "fr" }, + # { "value": "University of Montreal", "types": ["alias"], "lang": "en" } + # ] + # } def org_language(item:) - dflt = I18n.default_locale || 'en' - return dflt unless item.present? - - labels = item.fetch('labels', [{ iso639: dflt }]) - labels.first&.fetch('iso639', I18n.default_locale) || dflt + ror_display_entry(item: item)['lang'] || I18n.default_locale.to_s end # Extracts the website domain from the item + # "links": [ + # { "type": "website", "value": "https://example.edu" }, + # { "type": "Wikipedia", "value": "https://en.wikipedia.org/wiki/Example_University" } + # ] def org_website(item:) - return nil unless item.present? && item.fetch('links', [])&.any? - return nil if item['links'].first.blank? + link = org_url(item: item) + return nil unless link.present? # A website was found, so extract just the domain without the www domain_regex = %r{^(?:http://|www\.|https://)([^/]+)} - website = item['links'].first.scan(domain_regex).last.first + matches = link.scan(domain_regex) + return nil if matches.empty? + + website = matches.last.first website.gsub('www.', '') end - # Extracts the FundRef Id if available + # Extracts the FundRef Id from external ids if available + # "external_ids": [ + # {"type": "fundref", "preferred": "12345", "all": ["12345", "67890"]}, + # {"type": "SomeOtherID", "preferred": "501100000000", "all": ["501100000000"]} + # ] def fundref_id(item:) - return '' unless item.present? && item['external_ids'].present? - return '' unless item['external_ids'].fetch('FundRef', {}).any? + external_ids = item['external_ids'] + + fundref = external_ids.find { |id| id['type'] == 'fundref' } + return '' unless fundref.present? # If a preferred Id was specified then use it - ret = item['external_ids'].fetch('FundRef', {}).fetch('preferred', '') - return ret if ret.present? + preferred = fundref['preferred'] + return preferred if preferred.present? # Otherwise take the first one listed - item['external_ids'].fetch('FundRef', {}).fetch('all', []).first + all = fundref['all'] + return all.first if all.present? + + '' end end end diff --git a/app/services/org_selection/hash_to_org_service.rb b/app/services/org_selection/hash_to_org_service.rb index 27b2e6ef5a..a301d5faaa 100644 --- a/app/services/org_selection/hash_to_org_service.rb +++ b/app/services/org_selection/hash_to_org_service.rb @@ -123,9 +123,19 @@ def abbreviation_from_hash(hash:) # Get the language from the hash or use the default def language_from_hash(hash:) - return Language.default unless hash.present? && hash[:language].present? + abbr = hash&.dig(:language) + # RorService.org_language returns I18n.default_locale.to_s as a fallback + return Language.default if abbr.blank? || abbr == I18n.default_locale.to_s - Language.where(abbreviation: hash[:language]).first || Language.default + # ROR provides ISO 639-1 codes (e.g., "en"). Attempt to match against BCP 47 tags (e.g., "en-CA") in db + pattern = "#{ActiveRecord::Base.sanitize_sql_like(abbr)}-%" + + results = Language.where('abbreviation = ? OR abbreviation LIKE ?', abbr, pattern) + .order(default_language: :desc) # prefer default language if multiple results exist + .to_a + + # Return (in order): exact match || first pattern match || app default language + results.find { |lang| lang.abbreviation == abbr } || results.first || Language.default end def identifier_keys diff --git a/app/services/orgs/cleanup_unmanaged_orgs_with_users_service.rb b/app/services/orgs/cleanup_unmanaged_orgs_with_users_service.rb new file mode 100644 index 0000000000..e8130d9c83 --- /dev/null +++ b/app/services/orgs/cleanup_unmanaged_orgs_with_users_service.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Orgs + # Invoked by the `orgs:cleanup_unmanaged_orgs_with_users` Rake task. + # Cleanup service for unmanaged orgs having 1 or more users. + # For each such org: + # - Reassign associated users and plans to the "default org" + # - Attempt deletion of the org + module CleanupUnmanagedOrgsWithUsersService + extend self + + Result = Struct.new( + :processed_orgs_count, + :reassigned_users_count, + :reassigned_plans_count, + :deleted_orgs_count, + :failed_deletions_count, + keyword_init: true + ) + + def run + result = initial_result + + default_org = fetch_default_org + + unmanaged_orgs_with_users.find_each do |org| + result.processed_orgs_count += 1 + Org.transaction do + reassign_users_to_default_org(org, default_org, result) + reassign_plans_to_default_org(org, default_org, result) + end + # Don't rollback org.users + org.plans reassignment if the org cannot be deleted + handle_org_deletion(org, result) + end + + print_summary(result) + end + + private + + def initial_result + Result.new( + processed_orgs_count: 0, + reassigned_users_count: 0, + reassigned_plans_count: 0, + deleted_orgs_count: 0, + failed_deletions_count: 0 + ) + end + + def fetch_default_org + default_org = Org.find(Rails.application.config.default_funder_id) + puts '----------------------------------------------------------------------------------------------' + puts "Using Org with id: '#{default_org.id}', name: '#{default_org.name}' for users/plans reassignment." + default_org + end + + def unmanaged_orgs_with_users + # Fetch unmanaged orgs that have one or more users + orgs = Org.unmanaged.joins(:users).distinct + puts "Found #{orgs.count} unmanaged org(s) with users to process." + orgs + end + + def reassign_users_to_default_org(org, default_org, result) + reassigned_count = org.users.update_all(org_id: default_org.id) + result.reassigned_users_count += reassigned_count + puts "✅ Reassigned #{reassigned_count} user(s) from '#{org.name}'." + end + + def reassign_plans_to_default_org(org, default_org, result) + reassigned_count = org.plans.update_all(org_id: default_org.id) + result.reassigned_plans_count += reassigned_count + puts "✅ Reassigned #{reassigned_count} plan(s) from '#{org.name}'." + end + + def handle_org_deletion(org, result) + if org.destroy + increment_successful_org_deletions(org, result) + else + msg = org.errors.full_messages.presence || ["Unknown deletion error (org inspect: #{org.inspect})"] + increment_failed_org_deletions(org, result, msg) + end + rescue StandardError => e + increment_failed_org_deletions(org, result, ["Exception: #{e.message}"]) + end + + def increment_successful_org_deletions(org, result) + result.deleted_orgs_count += 1 + puts "✅ Deleted unmanaged org: #{org.id} - #{org.name}" + end + + def increment_failed_org_deletions(org, result, messages) + result.failed_deletions_count += 1 + puts "⚠️ Failed to delete unmanaged org: #{org.id} - #{org.name}: #{messages.join(', ')}" + end + + def print_summary(result) + puts <<~MSG + ----- Summary ----- + Unmanaged orgs processed: #{result.processed_orgs_count} + Users reassigned: #{result.reassigned_users_count} + Plans reassigned: #{result.reassigned_plans_count} + Successfully deleted orgs: #{result.deleted_orgs_count} + Failed org deletions: #{result.failed_deletions_count} + MSG + end + end +end diff --git a/app/views/api/v2/_standard_response.json.jbuilder b/app/views/api/v2/_standard_response.json.jbuilder new file mode 100644 index 0000000000..372c42cb31 --- /dev/null +++ b/app/views/api/v2/_standard_response.json.jbuilder @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# locals: response, request, total_items + +total_items ||= 0 + +paginator = Api::V2::PaginationPresenter.new(current_url: request.path, + per_page: @per_page, + total_items: total_items, + current_page: @page) + +json.prettify! +json.ignore_nil! + +json.application @application +json.source "#{request.method} #{request.path}" +json.time Time.now.to_formatted_s(:iso8601) +json.caller @caller +json.code response.status +json.message Rack::Utils::HTTP_STATUS_CODES[response.status] + +if response.status == 200 + + # Pagination Links + if total_items.positive? + json.page @page + json.per_page @per_page + json.total_items total_items + + # Prepare the base URL by removing the old pagination params + json.prev paginator.prev_page_link if paginator.prev_page? + json.next paginator.next_page_link if paginator.next_page? + else + json.total_items 0 + end + +end diff --git a/app/views/api/v2/contributors/_show.json.jbuilder b/app/views/api/v2/contributors/_show.json.jbuilder new file mode 100644 index 0000000000..80e6d27f3a --- /dev/null +++ b/app/views/api/v2/contributors/_show.json.jbuilder @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# locals: contributor, is_contact + +is_contact ||= false + +name = contributor.is_a?(User) ? contributor.name(false) : contributor.name +json.name sanitize(name) +json.mbox contributor.email + +if !is_contact && contributor.selected_roles.any? + roles = contributor.selected_roles.map do |role| + Api::V2::ContributorPresenter.role_as_uri(role: role) + end + json.role roles if roles.any? +end + +if contributor.org.present? + json.affiliation do + json.partial! 'api/v2/orgs/show', org: contributor.org + end +end + +orcid = contributor.identifier_for_scheme(scheme: 'orcid') +if orcid.present? + id = Api::V2::ContributorPresenter.contributor_id( + identifiers: contributor.identifiers + ) + if is_contact + json.contact_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + else + json.contributor_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + end +end diff --git a/app/views/api/v2/datasets/_show.json.jbuilder b/app/views/api/v2/datasets/_show.json.jbuilder new file mode 100644 index 0000000000..301a7d0f9d --- /dev/null +++ b/app/views/api/v2/datasets/_show.json.jbuilder @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# locals: output + +if output.is_a?(ResearchOutput) + presenter = Api::V2::ResearchOutputPresenter.new(output: output) + + json.type output.output_type + json.title strip_tags(output.title) + json.description sanitize(output.description) + json.personal_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) + json.sensitive_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) + json.issued output.release_date&.to_formatted_s(:iso8601) + + json.preservation_statement presenter.preservation_statement + json.security_and_privacy presenter.security_and_privacy + json.data_quality_assurance presenter.data_quality_assurance + + json.dataset_id do + json.partial! "api/v2/identifiers/show", identifier: presenter.dataset_id + end + + json.distribution output.repositories do |repository| + json.title "Anticipated distribution for #{output.title}" + json.byte_size output.byte_size + json.data_access output.access + + json.host do + json.title repository.name + json.description repository.description + json.url repository.homepage + + # DMPTool extensions to the RDA common metadata standard + json.dmproadmap_host_id do + json.type "url" + json.identifier repository.uri + end + end + + if output.license.present? + json.license [output.license] do |license| + json.license_ref license.uri + json.start_date presenter.license_start_date + end + end + end + + json.metadata output.metadata_standards do |metadata_standard| + website = metadata_standard.locations.find { |loc| loc["type"] == "website" } + website = { url: "" } unless website.present? + + descr_array = [metadata_standard.title, metadata_standard.description, website["url"]] + json.description descr_array.join(" - ") + + json.metadata_standard_id do + json.type "url" + json.identifier metadata_standard.uri + end + end + + json.technical_resource [] + + if output.plan.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.plan.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end + +else + json.type "dataset" + json.title "Generic dataset" + json.description "No individual datasets have been defined for this DMP." + + if output.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end +end diff --git a/app/views/api/v2/error.json.jbuilder b/app/views/api/v2/error.json.jbuilder new file mode 100644 index 0000000000..ac08f26d9f --- /dev/null +++ b/app/views/api/v2/error.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +# json.items [] +json.message @payload[:message] +json.details @payload[:details] diff --git a/app/views/api/v2/heartbeat.json.jbuilder b/app/views/api/v2/heartbeat.json.jbuilder new file mode 100644 index 0000000000..70b165b95d --- /dev/null +++ b/app/views/api/v2/heartbeat.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +json.items [] diff --git a/app/views/api/v2/identifiers/_show.json.jbuilder b/app/views/api/v2/identifiers/_show.json.jbuilder new file mode 100644 index 0000000000..c219222aee --- /dev/null +++ b/app/views/api/v2/identifiers/_show.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# locals: identifier + +json.type identifier&.identifier_format +json.identifier identifier&.value diff --git a/app/views/api/v2/me.json.jbuilder b/app/views/api/v2/me.json.jbuilder new file mode 100644 index 0000000000..18a1e7c114 --- /dev/null +++ b/app/views/api/v2/me.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +if current_user.present? + json.items [current_user] do |user| + json.name [user.surname, user.firstname].join(', ') + json.mbox user.email + json.token user.ui_token + + if user.org.present? && ['No funder', 'Non Partner Institution'].exclude?(user.org.name) + json.affiliation do + json.partial! 'api/v2/orgs/show', org: user.org + end + end + + orcid = user.identifier_for_scheme(scheme: 'orcid') + if orcid.present? + json.user_id do + json.partial! 'api/v2/identifiers/show', identifier: orcid + end + end + end + +else + json.items [] +end diff --git a/app/views/api/v2/orgs/_show.json.jbuilder b/app/views/api/v2/orgs/_show.json.jbuilder new file mode 100644 index 0000000000..934e429a17 --- /dev/null +++ b/app/views/api/v2/orgs/_show.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# locals: org + +json.name org.name +json.abbreviation org.abbreviation +json.region org.region&.abbreviation + +if org.identifiers.any? + json.affiliation_id do + id = Api::V2::OrgPresenter.affiliation_id(identifiers: org.identifiers) + json.partial! 'api/v2/identifiers/show', identifier: id + end +end diff --git a/app/views/api/v2/plans/_cost.json.jbuilder b/app/views/api/v2/plans/_cost.json.jbuilder new file mode 100644 index 0000000000..ac0d0a5add --- /dev/null +++ b/app/views/api/v2/plans/_cost.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# locals: cost + +json.title sanitize(cost[:title]) +json.description cost[:description] +json.currency_code cost[:currency_code] +json.value sanitize(cost[:value]) diff --git a/app/views/api/v2/plans/_funding.json.jbuilder b/app/views/api/v2/plans/_funding.json.jbuilder new file mode 100644 index 0000000000..35786ac2dc --- /dev/null +++ b/app/views/api/v2/plans/_funding.json.jbuilder @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# locals: plan + +json.name plan.funder&.name + +if plan.funder.present? + id = Api::V2::OrgPresenter.affiliation_id(identifiers: plan.funder.identifiers) + + if id.present? + json.funder_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + end +end + +if plan.grant_id.present? && plan.grant.present? + json.grant_id do + json.partial! 'api/v2/identifiers/show', identifier: plan.grant + end +end + +json.funding_status Api::V2::FundingPresenter.status(plan: plan) + +# DMPTool extensions to the RDA common metadata standard +# ------------------------------------------------------ + +# We collect a user entered ID on the form, so this is a way to convey it to other systems +# The ID would typically be something relevant to the funder or research organization +if plan.identifier.present? + json.dmproadmap_funding_opportunity_id do + json.partial! 'api/v2/identifiers/show', identifier: Identifier.new(identifiable: plan, + value: plan.identifier) + end +end + +# Since the Plan owner (aka contact) and contributor orgs could be different than the +# one associated with the Plan, we add it here. +json.dmproadmap_funded_affiliations [plan.org] do |funded_org| + json.partial! 'api/v2/orgs/show', org: funded_org +end diff --git a/app/views/api/v2/plans/_project.json.jbuilder b/app/views/api/v2/plans/_project.json.jbuilder new file mode 100644 index 0000000000..9bf3f97fea --- /dev/null +++ b/app/views/api/v2/plans/_project.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# locals: plan + +json.title plan.title +json.description plan.description + +start_date = plan.start_date || Time.now +json.start start_date.to_formatted_s(:iso8601) + +end_date = plan.end_date || (Time.now + 2.years) +json.end end_date&.to_formatted_s(:iso8601) + +if plan.funder.present? || plan.grant_id.present? + json.funding [plan] do + json.partial! 'api/v2/plans/funding', plan: plan + end +end diff --git a/app/views/api/v2/plans/_show.json.jbuilder b/app/views/api/v2/plans/_show.json.jbuilder new file mode 100644 index 0000000000..6029f88c0d --- /dev/null +++ b/app/views/api/v2/plans/_show.json.jbuilder @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# locals: plan + +json.schema 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard/tree/master/examples/JSON/JSON-schema/1.0' + +presenter = Api::V2::PlanPresenter.new(plan: plan, complete: @complete) + +# Note the symbol of the dmproadmap json object +# nested in extensions which is the container for the json template object, etc. + +# A JSON representation of a Data Management Plan in the +# RDA Common Standard format +json.title plan.title +json.description plan.description +json.language Api::V2::LanguagePresenter.three_char_code( + lang: plan.owner&.language&.abbreviation +) +json.created plan.created_at.to_formatted_s(:iso8601) +json.modified plan.updated_at.to_formatted_s(:iso8601) + +json.ethical_issues_exist Api::V2::ConversionService.boolean_to_yes_no_unknown(plan.ethical_issues) +json.ethical_issues_description sanitize(plan.ethical_issues_description) +json.ethical_issues_report plan.ethical_issues_report + +id = presenter.identifier +if id.present? + json.dmp_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end +end + +if presenter.data_contact.present? + json.contact do + json.partial! 'api/v2/contributors/show', contributor: presenter.data_contact, + is_contact: true + end +end + +unless @minimal + if presenter.contributors.any? + json.contributor presenter.contributors do |contributor| + json.partial! 'api/v2/contributors/show', contributor: contributor, + is_contact: false + end + end + + if presenter.costs.any? + json.cost presenter.costs do |cost| + json.partial! 'api/v2/plans/cost', cost: cost + end + end + + json.project [plan] do |pln| + json.partial! 'api/v2/plans/project', plan: pln + end + + outputs = plan.research_outputs.any? ? plan.research_outputs : [plan] + + json.dataset outputs do |output| + json.partial! "api/v2/datasets/show", output: output + end + + json.extension [plan.template] do |template| + json.set! :dmproadmap do + json.template do + json.id template.id + json.title strip_tags(template.title) + end + end + + if @complete + json.complete_plan do + q_and_a = presenter.complete_plan_data + next if q_and_a.blank? + + json.array! q_and_a do |item| + json.question_id item[:id] + json.title item[:title] + json.section strip_tags(item[:section]) + json.question sanitize(item[:question]) + json.answer sanitize(item[:answer]) + end + end + end + end +end diff --git a/app/views/api/v2/plans/index.json.jbuilder b/app/views/api/v2/plans/index.json.jbuilder new file mode 100644 index 0000000000..f19f41d1dc --- /dev/null +++ b/app/views/api/v2/plans/index.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response', total_items: @total_items + +json.items @items do |item| + json.dmp do + json.partial! 'api/v2/plans/show', plan: item + end +end diff --git a/app/views/api/v2/templates/index.json.jbuilder b/app/views/api/v2/templates/index.json.jbuilder new file mode 100644 index 0000000000..6a29c6e3f4 --- /dev/null +++ b/app/views/api/v2/templates/index.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response', total_items: @total_items + +json.items @items do |template| + presenter = Api::V2::TemplatePresenter.new(template: template) + + json.dmp_template do + json.title strip_tags(presenter.title) + json.description sanitize(template.description) + json.version template.version + json.created template.created_at.to_formatted_s(:iso8601) + json.modified template.updated_at.to_formatted_s(:iso8601) + + json.affiliation do + json.partial! 'api/v2/orgs/show', org: template.org + end + + json.template_id do + identifier = Api::V2::ConversionService.to_identifier(context: @application, + value: template.id) + json.partial! 'api/v2/identifiers/show', identifier: identifier + end + end +end diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index 704be320b1..783c3bc9f5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,25 +1,12 @@ <%# locals: user %> +<% v2_token ||= nil %> -<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> -
-
- <%= label_tag(:api_token, _('Access token'), class: 'control-label') %> - <% if user.api_token.present? %> - <%= user.api_token %> - <% else %> - <%= _("Click the button below to generate an API token") %> - <% end %> -
-
- <%= label_tag(:api_information, _('Documentation'), class: 'control-label') %> -
- <%= _('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.').html_safe % { api_v0_wiki: api_wikis[:v0] } %> -

- <%= _('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %> -
-
- <%= link_to _("Regenerate token"), - refresh_token_user_path(user), - class: "btn btn-default", remote: true %> -
+
+ <%# v2 API token %> + <%= render partial: "devise/registrations/v2_api_token", locals: { user: user, token: v2_token } %> + + <% if user.can_use_api? %> + <%# v0/v1 API token %> + <%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %> + <% end %>
diff --git a/app/views/devise/registrations/_legacy_api_token.html.erb b/app/views/devise/registrations/_legacy_api_token.html.erb new file mode 100644 index 0000000000..6def1bfa95 --- /dev/null +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -0,0 +1,36 @@ +<%# locals: user %> + +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> +
+ +
+
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if user.api_token.present? %> + <%= user.api_token %> + <% else %> + <%= _("Click the button below to generate an API token") %> + <% end %> +
+
+ <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> +
+ <%= sanitize(_('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.') % + { api_v0_wiki: api_wikis[:v0] }, + attributes: %w[href] + )%> +

+ <%= sanitize(_('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.') % + { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' }, + attributes: %w[href] + )%> +
+
+ <%= link_to _("Regenerate token"), + refresh_token_user_path(user), + class: "btn btn-default", remote: true %> +
+
+
diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb new file mode 100644 index 0000000000..9b8043b95d --- /dev/null +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -0,0 +1,64 @@ +<%# locals: user %> +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> + +
+ +
+ <% if Api::V2::InternalUserAccessTokenService.application_present? %> +
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if token.present? %> + <%= text_field_tag( + :api_token_val, + token, + id: 'api-token-val', + class: 'form-control', + style: 'width: auto;', + readonly: true + ) %> + + <%= button_tag( + _('Copy'), + id: 'copy-token-btn', + type: 'button', + class: 'btn btn-default' + ) %> +
+ <%= _( "This token will expire after 24 hours." ) %>
+ <%= _( "Please copy it now and store it somewhere safely." ) %>
+ <%= _( "It will disappear after you leave or refresh this page." ) %> +
+ <% else %> + <%= _( "Click the button below to generate an API token" ) %>
+
+ <%= _("Tokens expire after 24 hours.") %>
+ <%= _("If you previously generated and saved a valid token, please continue using it.") %> +
+ <% end %> +
+ +
+ <%= button_tag _("Regenerate token"), + type: :submit, + class: "btn btn-default", + disabled: token.present?, + data: { remote: true, url: api_v2_internal_user_access_token_path, method: :post } %> +
+
+ <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> +
+ <%= sanitize(_('See the documentation for v2 for more details on the API.') % + { api_v2_wiki: api_wikis[:v2] }, + attributes: %w[href] + )%> +
+ <% else %> +
+ <%= _("V2 API token service is currently unavailable. Please contact us for help.") %> + <%= mail_to Rails.application.config.x.organisation.helpdesk_email %> +
+ <% end %> +
+
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index f83d208068..906243caa5 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -16,12 +16,10 @@ <%= _('Password') %> - <% if @user.can_use_api? %> - - <% end %> +
- <% if @user.can_use_api? %> -
-
-
- <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %> -
+
+
+
+ <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
- <% end %> +
diff --git a/app/views/doorkeeper/applications/_delete_form.html.erb b/app/views/doorkeeper/applications/_delete_form.html.erb new file mode 100644 index 0000000000..b6377d35f5 --- /dev/null +++ b/app/views/doorkeeper/applications/_delete_form.html.erb @@ -0,0 +1,6 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.applications.buttons.destroy'), + onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')", + class: submit_btn_css, style: "border-radius: 0;" %> +<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb new file mode 100644 index 0000000000..5aeeeaf9ab --- /dev/null +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -0,0 +1,42 @@ +<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: 'form' } do |f| %> + <% if application.errors.any? %> +

<%= t('doorkeeper.applications.form.error') %>

+ <% end %> + +
+ <%= f.label :name, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true, style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :name %> +
+
+ +
+ <%= f.label "Scope(s)", class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }", style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :scopes %> + + <%= "Separate multiple scopes with spaces." %> + +
+
+ +
+ <%= f.label nil, 'Redirect URI(s)'.html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }", style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :redirect_uri %> + + <%= "Separate multiple URIs with spaces." %> + +
+
+ +
+
+ <%= f.submit t('doorkeeper.applications.buttons.submit'), class: 'btn btn-primary' %> + <%= link_to t('doorkeeper.applications.buttons.cancel'), oauth_applications_path, class: 'btn btn-secondary' %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb new file mode 100644 index 0000000000..95fb9ab2d5 --- /dev/null +++ b/app/views/doorkeeper/applications/edit.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb new file mode 100644 index 0000000000..835e0d917f --- /dev/null +++ b/app/views/doorkeeper/applications/index.html.erb @@ -0,0 +1,34 @@ +
+

<%= t('.title') %>

+
+ +

<%= link_to t('.new'), new_oauth_application_path, class: 'btn btn-secondary', style: "border-radius: 0;" %>

+ + + + + + + + + + + + <% @applications.each do |application| %> + + + + + + + <% end %> + +
<%= t('.name') %><%= t('.callback_url') %><%= t('.actions') %>
+ <%= link_to application.name, oauth_application_path(application) %> + + <%= simple_format(application.redirect_uri) %> + + <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %> + + <%= render 'delete_form', application: application %> +
\ No newline at end of file diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb new file mode 100644 index 0000000000..95fb9ab2d5 --- /dev/null +++ b/app/views/doorkeeper/applications/new.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb new file mode 100644 index 0000000000..71e06b573e --- /dev/null +++ b/app/views/doorkeeper/applications/show.html.erb @@ -0,0 +1,42 @@ +<%= fields_for @application, as: :doorkeeper_application, html: { role: 'form' } do |f| %> +
+ <%= f.label :name, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :name, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, "Client ID".html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :uid, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, "Client secret".html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :secret, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label "Scope(s)", class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :scopes, class: "form-control", style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, 'Redirect URI(s)'.html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_area :redirect_uri, class: "form-control", style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+
+ <%= link_to "Back", oauth_applications_path, class: 'btn btn-secondary' %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 0000000000..4173caf8f2 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,9 @@ +
+

<%= t('doorkeeper.authorizations.error.title') %>

+
+ +
+
+    <%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
+  
+
\ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/form_post.html.erb b/app/views/doorkeeper/authorizations/form_post.html.erb new file mode 100644 index 0000000000..4b0df6c0c6 --- /dev/null +++ b/app/views/doorkeeper/authorizations/form_post.html.erb @@ -0,0 +1,15 @@ + + +<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false do %> + <% auth.body.compact.each do |key, value| %> + <%= hidden_field_tag key, value %> + <% end %> +<% end %> + + \ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 0000000000..c7b8d0f224 --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,46 @@ + + +
+

+ <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %> +

+ + <% if @pre_auth.scopes.count > 0 %> +
+

<%= t('.able_to') %>:

+ +
    + <% @pre_auth.scopes.each do |scope| %> +
  • <%= t(scope, scope: [:doorkeeper, :scopes]) + " your plans" %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success mt-3 mb-3", style: "border-radius: 0;" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger", style: "border-radius: 0;" %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 0000000000..385fc9f24b --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,7 @@ + + +
+ <%= params[:code] %> +
\ No newline at end of file diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.erb b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb new file mode 100644 index 0000000000..b39ef93edb --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb @@ -0,0 +1,4 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_authorized_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.authorized_applications.buttons.revoke'), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %> +<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 0000000000..08e8429fd2 --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,24 @@ + + +
+ + + + + + + + + + <% @applications.each do |application| %> + + + + + + <% end %> + +
<%= t('doorkeeper.authorized_applications.index.application') %><%= t('doorkeeper.authorized_applications.index.created_at') %>
<%= application.name %><%= application.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %><%= render 'delete_form', application: application %>
+
\ No newline at end of file diff --git a/app/views/home/_welcome.html.erb b/app/views/home/_welcome.html.erb index 66df0ce846..306d04bf45 100644 --- a/app/views/home/_welcome.html.erb +++ b/app/views/home/_welcome.html.erb @@ -3,87 +3,150 @@ %> <% title _("Welcome to %{application_name}.") % {application_name: application_name} %> -
-

<%= _("Welcome to %{application_name}.") % {application_name: application_name}%>

+
- <% +

+ <%= _("Welcome to %{application_name}.") % {application_name: application_name} %> +

- landing_page_content = format(_(%{ - +
+
+
+ <%= image_tag "available.png", id: 'availability-logo', alt: _('Availability icon') %> +

- You can sign in with your institutional credentials by clicking the "Sign in with Institutional or Social ID" button, or you can make an account by clicking the "Create an Account" tab. If your institution is not listed, you can create an account under the Digital Research Alliance of Canada, which is open to any users. + <%= _('Available and accessible throughout the world, while being Canada-focused') %> +

+
+ +
+
+ <%= image_tag "collaborative.png", id: 'collaborative-logo', alt: _('Collaboration icon') %> +
+

+ <%= _('Collaborate on plans by sharing with other users or describing your research team as you need to') %>

+
+
+
+ <%= image_tag "lifecycle.png", id: 'lifecycle-logo', alt: _('Lifecycle icon') %> +
+

+ <%= _('Planning is an integral part of the research data management lifecycle') %> +

+
+
+ + <% + section_1 = format(_(%{

- DMP Assistant is a national, online, bilingual data management planning tool developed by the Digital Research Alliance of Canada (the Alliance) in collaboration with University of Alberta, and the Arbutus team at the University of Victoria to assist researchers in preparing data management plans (DMPs). This tool is freely available to all researchers, and develops a DMP through a series of key data management questions, supported by best-practice guidance and examples. + You can sign in with your institutional credentials by clicking the "Sign in with Institutional or Social ID" button, or you can make an account by clicking the "Create an Account" tab. If your institution is not listed, you can create an account under the Digital Research Alliance of Canada, which is open to any users.

+ })) + %> + <% + section_2 = format(_(%{

- DMPs are one of the foundations of good research data management (RDM), an international best practice, and increasingly required by institutions and funders, including the Canadian Tri-Agencies as outlined in their Research Data Management Policy. + DMP Assistant is a national, online, bilingual data management planning tool developed by the Digital Research Alliance of Canada (the Alliance) in + collaboration with University of Alberta, and the Arbutus team at the University of Victoria to assist researchers in preparing data management plans (DMPs). + This tool is freely available to all researchers, and develops a DMP through a series of key data management questions, supported by best-practice guidance and examples.

+ })) + %> - Getting started: + <% + section_3 = format(_(%{ +

+ DMPs are documents that form one of the foundations of good research data management (RDM), an international best practice, and increasingly required by institutions and funders, including the Canadian Tri-Agencies as outlined in their + Research Data Management Policy. +

+ })) + %> + <% + section_4 = format(_(%{

- For more resources and training materials spanning the entire research data life cycle, see the Alliance Training Resources. + For more resources and training materials spanning the entire research data life cycle, see the + Alliance Training Resources.

+ + }), training_resources_path: training_resources_path) + %> + <% + section_5 = format(_(%{

- DMP Assistant was adapted from the Digital Curation Centre (DCC)’s DMPonline tool, and uses the DMP Roadmap codebase developed by DCC and the University of California Curation Center (UC3). + DMP Assistant was adapted from the + Digital Curation Centre (DCC)’s + DMPonline tool, and uses the DMP Roadmap codebase developed by DCC and the + University of California Curation Center (UC3).

DMP Assistant was originally developed for Canada by the Portage Network, then a part of the Canadian Association of Research Libraries (CARL). In 2021, Portage Network and its portfolio of platforms and services merged into the Digital Research Alliance of Canada. In 2023 and 2024, we have undertaken a rebrand of the platform, several templates and the organization previously called Portage have transitioned to Alliance branding.

- }),how_to_manage_your_data_path: how_to_manage_your_data_path, training_resources_path: training_resources_path) - + })) %> +
-<%= sanitize(landing_page_content) %> +<% + faqs = [ + { + question: _('How can I sign in through my institution?'), + answer: section_1 + }, + { + question: _('What is DMP Assistant?'), + answer: section_2 + }, + { + question: _('What are Data Management Plans (DMPs)?'), + answer: section_3 + }, + { + question: _('Getting Started - Tutorials and Webinars'), + answer: section_4 + }, + { + question: _('History of DMP Assistant'), + answer: section_5 + } + ] +%> +
+ <% faqs.each do |faq| %> +
+ <%= faq[:question] %> + <%= sanitize(faq[:answer]) %> +
+ <% end %>
\ No newline at end of file diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index 8d451c38be..1d0fa96909 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -121,6 +121,9 @@ <% end %> <% if current_user.can_super_admin? %> +
  • > <%= link_to(_('Api Clients'), super_admin_api_clients_path) %>
  • diff --git a/app/views/layouts/_header_navigation_delete.html.erb b/app/views/layouts/_header_navigation_delete.html.erb index 4000d3351e..4a62500ece 100644 --- a/app/views/layouts/_header_navigation_delete.html.erb +++ b/app/views/layouts/_header_navigation_delete.html.erb @@ -24,7 +24,7 @@ <%= link_to _('My Dashboard'), plans_path %>
  • > - <%= link_to _('Create plans'), new_plan_path %> + <%= link_to _('Create plan'), new_plan_path, id: "create-plan-link" %>
  • \ No newline at end of file diff --git a/app/views/static_pages/about_us.html.erb b/app/views/static_pages/about_us.html.erb index 99d6abe52b..14a03cdfc8 100644 --- a/app/views/static_pages/about_us.html.erb +++ b/app/views/static_pages/about_us.html.erb @@ -10,71 +10,34 @@

    <%= _(Rails.configuration.branding[:application][:name]) %>

    -

    <%= _('DMP Assistant is an all-purpose tool for preparing data management plans (DMPs).') %>

    -

    <%= _('Researchers will be guided through best practices in data stewardship.') %>

    - -
      -
    • <%= _('The tool walks researchers step-by-step through a number of key questions about data management.') %>
    • -
    • <%= _('Guidance and examples are provided.') %>
    • -
    • <%= _('Not all questions will apply to all research projects. Researchers are encouraged to answer the questions relevant to their work.') %>
    • -
    • <%= _('Researchers should revisit the tool throughout their research to review or complete their responses.') %>
    • -
    - -

    <%= _('How the tool works') %>

    - -

    <%= _('Currently, this tool includes a generic Alliance template for researchers to use. Guidance is provided to help you interpret and answer the questions.') %>

    - -

    - <% email_link = Rails.configuration.branding[:organisation][:helpdesk_email] %> - <% email_subject = _('DMP Assistant feedback').gsub(' ', '%20') %> - <% contact_your_institution_url = contacts_at_your_instutution_path %> - <%= sanitize(_('We want your feedback! If you have suggestions on how to improve the existing template, or you would like to add additional templates based on funding agency requirements or disciplinary needs, please contact your local institution or contact us at %{email_link}.') % {contact_your_institution_url: contact_your_institution_url, email_link: email_link, email_subject: email_subject}) %> + <%= sanitize(_('The DMP Assistant is a national, online, bilingual data management planning tool developed and supported by the Digital Research Alliance of Canada (formerly the Portage Network), with partnerships through the University of Alberta Library (development partner) and Arbutus at the University of Victoria (infrastructure partner) to assist researchers in preparing data management plans (DMPs). The tool can also help organizations manage organizational DMP services through customizable organizational profiles, templates, guidance, and usage reporting.') % + { dmp_url: "https://dmp-pgd.ca" }) %>

    -

    <%= _('Getting started') %>

    - -

    <%= _("If you have an account, please sign in and start creating or editing your DMP.") %>

    +

    <%= _('Researchers will be guided using best practices in data stewardship:') %>

    - <% unless user_signed_in? %> -

    - <% sign_up_url = request.base_url %> - <%= sanitize(_('If you do not have a DMP Assistant account, click on \'Sign up\' on the homepage.') % { sign_up_url: sign_up_url} ) %> -

    - -

    - <%= _('You may sign up for an account as part of your institution, or if you do not have one you may sign up under the Digital Research Alliance of Canada.') %> -

    - <% end %> - -

    - <%= _('DMP Assistant has recently implemented single sign on, which will allow you to use CI Logon to use institutional or social accounts in order to sign into your DMP Assistant account.') %> -

    - -

    - <% help_url = help_path %> - <%= sanitize(_('Please visit the \'Help\' page for guidance.') % {help_url: help_path}) %> -

    - -

    <%= _('Feedback') %>

    - -

    - <%= sanitize(_('If you have any questions or comments about the DMP Assistant, contact your local institution or email us at %{email_link}.') % { contact_your_institution_url: contact_your_institution_url, email_link: email_link, email_subject: email_subject} ) %> -

    +
      +
    • <%= _('The tool walks researchers step-by-step through a number of key questions regarding different components of data management.') %>
    • +
    • <%= _('Guidance and examples are available.') %>
    • +
    • <%= _('Not all questions may apply to all research projects. Researchers and users are encouraged to answer the questions relevant to their work, while ensuring consideration is given to all questions before determining if a question is not relevant.') %>
    • +
    • <%= _('Researchers and users should revisit the tool throughout the lifecycle of their research project to review, complete or adjust their responses as the project progresses.') %>
    • +
    -

    <%= _('About templates') %>

    - -

    - <%= _('The Alliance template is based on internationally accepted standards and best practices. It has been prepared and is maintained by a group of research data management experts from research libraries across Canada. It was developed by the Data Management Planning Expert Group, then part of the Portage Network. Portage became part of the Digital Research Alliance of Canada in 2021.') %> -

    +

    <%= _('Historical Context') %>

    - <%= _('Organizations may provide their own DMP templates. The Alliance is the default DMP. Select templates from other organizations from the drop-down Organization list when creating your plan. Users can make use of templates in any available organization, and collaborate across organizations.') %> + <%= sanitize(_('Launched in 2015, the DMP Assistant was adapted from the Digital Curation Centre (DCC)’s DMPonline tool and uses the DMP Roadmap codebase. DMP Assistant was originally developed for Canada by the Portage Network, then a part of the Canadian Association of Research Libraries (CARL). In 2021, Portage Network and its portfolio of platforms and services merged into the Digital Research Alliance of Canada. DMP Assistant in Canada is administered by the Digital Research Alliance of Canada, with support from development partner the University of Alberta Library and hosted on the Arbutus Cloud at the University of Victoria.') % + { + dcc_url: "https://dcc.ac.uk/", + dmponline_url: "https://dmponline.dcc.ac.uk/", + roadmap_url: "https://github.com/DMPRoadmap/roadmap" + }) %>

    -

    - <%= sanitize(_('For funding agencies, research institutions, and other organizations, please contact %{email_link} if you would like to add your organization templates to DMP Assistant.') % { email_link: email_link}) %> -

    +

    + <%= _('Contact us') %> +

    diff --git a/app/views/static_pages/help.html.erb b/app/views/static_pages/help.html.erb index d4e32c41fa..7ffe6d63f2 100644 --- a/app/views/static_pages/help.html.erb +++ b/app/views/static_pages/help.html.erb @@ -7,62 +7,148 @@
    -
    -

    <%= _("When you log in to DMP Assistant you will be directed to the 'My Dashboard' page. From here you can edit, share, download, copy or remove any of your plans. You will also see plans that have been shared with you by others.") %>

    -

    - <% email_link = Rails.configuration.branding[:organisation][:helpdesk_email] %> - <% email_subject = _('DMP Assistant feedback').gsub(' ', '%20') %> - <% contact_your_institution_url = contacts_at_your_instutution_path %> - <% how_to_manage_your_data_url = how_to_manage_your_data_path %> - <%= sanitize(_('If you need assistance in developing your data management plan, the following resource may help get you started: How to manage your data. If you require more guidance, contact us at dmp-assistant@tech.alliancecan.ca.') % {contact_your_institution_url: contact_your_institution_url, email_link: email_link, email_subject: email_subject, how_to_manage_your_data_url: how_to_manage_your_data_url}) %> -

    + +

    <%= _("General / Getting Started") %>

    +

    + <%= _("DMP Assistant provides users with the ability to create Data Management Plans (DMPs). In order to create a DMP within DMP Assistant, users will need to have created an account. Users can sign up for an account using their organization, or if they don’t belong to one, may sign up under the Digital Research Alliance of Canada. DMP Assistant also has the option of signing in using single sign on, which enables users to use CI Logon to sign in using institutional or social accounts.") %> +

    +

    + <%= _("Once users have logged in, they can create DMPs by selecting a template under the ‘Create Plan’ tab. Templates are designed for users to create DMPs by guiding users to answer the questions required of a DMP. There are specialized templates that may be useful, depending on the research project. All Alliance templates are available, but local templates may also be available depending on the user’s organization.") %> +

    -

    - <% sso_english_guidelines_url = 'https://drive.google.com/file/d/11m2lepJF7207uwqKwR0TzWDcPAZyq6Kb/view?usp=drive_link'%> - <% sso_french_guidelines_url = 'https://drive.google.com/file/d/1QIVhN6P566xUaOyVZMwtfz8a7fI4X1D_/view?usp=drive_link'%> - <%= sanitize(_('For help with single sign-on, help documents are available in English and in French.') % - {sso_french_guidelines_url: sso_french_guidelines_url, sso_english_guidelines_url: sso_english_guidelines_url } )%> -

    - -

    <%= _("Create a plan") %>

    + +

    <%= _("Help Desk") %>

    +

    + <%= _("If you have any questions that are not addressed here, or have any comments or concerns please email the DMP Assistant Service Team helpdesk at") %> + <%= mail_to Rails.configuration.x.organisation.helpdesk_email %> +

    -

    <%= _("To create a plan, click the 'Create plan' button from the 'My Dashboard' page or the top menu. Select options from the menus and tickboxes to determine what questions and guidance you should be presented with. Confirm your selection by clicking 'Create plan.'") %>

    + +

    <%= _("Resources") %>

    -

    <%= _("Write your plan") %>

    +

    <%= _("Video Primers") %>

    +
      +
    • <%= link_to _("Introduction to Data Management Plans (DMPs)"), _("https://youtu.be/L9AbOoHk-PA") %>
    • +
    • <%= link_to _("Introduction to DMP Assistant"), _("https://youtu.be/N_Mzq60D4nc") %>
    • +
    • <%= link_to _("Managing DMPs with DMP Assistant"), _("https://youtu.be/iY5tzxFwM8s") %>
    • +
    -

    <%= _("The tabbed interface allows you to navigate through different functions when editing your plan.") %>

    -
      -
    • <%= _("'Project Details' includes basic administrative details.") %>
    • -
    • <%= _("'Plan Overview' tells you what template and guidance your plan is based on and gives you an overview to the questions that you will be asked.") %>
    • -
    • <%= _("The following tab(s) present the questions to answer. There may be more than one tab if your funder or university asks different sets of questions at different stages e.g. at grant application and post-award.") %>
    • -
    • <%= _("'Share' allows you to invite others to read or contribute to your plan.") %>
    • -
    • <%= _("'Download' allows you to download your plan in various formats. This may be useful if you need to submit your plan as part of a grant application.") %>
    • -
    +

    <%= _("Webinars") %>

    +
      +
    • <%= link_to _("Supporting researchers in meeting DMP requirements"), "https://www.youtube.com/watch?v=uJYdlo0HeCk" %>
    • +
    • <%= link_to _("Support Your Research with DMP Assistant 2.0"), "https://www.youtube.com/watch?v=Cp1O7vXLPao" %>
    • +
    • <%= link_to _("Support Your Research with Data Management Planning"), "https://www.youtube.com/watch?v=uXjOKaJwYsk" %>
    • +
    -

    <%= _("When viewing any of the question tabs, you will see the different sections of your plan displayed. Click into these in turn to answer the questions. You can format your responses using the text editing buttons.") %>

    +

    <%= _("Other") %>

    +
      +
    • <%= link_to _("Alliance Simplified Template Rubric"), _("https://zenodo.org/records/15740791") %>
    • +
    -

    <%= _("Guidance is displayed in the right-hand panel. If you need more guidance or find there is too much, you can make adjustments on the ‘Project Details’ tab.") %>

    -

    <%= _("Share plans") %>

    + +

    <%= _("FAQs (Frequently Asked Questions)") %>

    -

    <%= _("Insert the email address of any collaborators you would like to invite to read or edit your plan. Set the level of permissions you would like to grant them via the radio buttons and click to 'Add collaborator.' Adjust permissions or remove collaborators at any time via the drop-down options.") %>

    +
    -

    <%= _("The ‘Share’ tab is also where you can set your plan visibility.") %>

    -
      -
    • <%= _("Private: restricted to you and your collaborators.") %>
    • -
    • <%= _("Organisational: anyone at your organisation can view your plan.") %>
    • -
    • <%= _("Public: anyone can view your plan in the Public DMPs list.") %>
    • -
    +
    + <%= _("What is a data management plan (DMP)?") %> +

    <%= _("A DMP is a written document that articulates strategies and tools that you are planning or using to effectively manage your research data. It addresses the management of research data before, during and after the active phases of your research project. It is considered a living document and can be updated and modified as needed throughout your research project.") %>

    +
    -

    <%= _("By default all new and test plans will be set to ‘Private’ visibility. ‘Public’ and ‘Organisational’ visibility are intended for finished plans.") %>

    +
    + <%= _("How do I create an account?") %> +

    <%= _("Visit dmp-pgd.ca. You can either create an account by selecting the ‘Create account’ tab and filling in your information, or by selecting ‘Signing in with institutional or social ID’.") %>

    +

    <%= _("Once you’ve created an account, an email confirmation will be sent, and you will need to confirm your email address before accessing the platform. If you require assistance with this step, please contact the help desk.") %>

    +
    -

    <%= _("Request feedback") %>

    -

    <%= _("There may also be an option to request feedback on your plan. This is available when research support staff at your organisation have enabled this service. Click to ‘Request feedback’ and your local administrators will be alerted to your request. Their comments will be visible in the ‘Comments’ field adjacent to each question. You will be notified by email when an administrator provides feedback.") %>

    +
    + <%= _("Does it cost anything to create an account?") %> +

    <%= _("No, DMP Assistant is a free to use platform for researchers and institutions.") %>

    +
    +
    + <%= _("Why doesn't my organisation appear in the list of available organisations when creating an account?") %> +

    <%= _("If your institution is not one of the available options, you can use the ‘Digital Research Alliance of Canada’, until your organisation has an account created, and then you may change your organisation on your profile page. Please contact your local organisation’s research data management contact to request the organisation be added to the platform.") %>

    +
    + +
    + <%= _("Why doesn't my organisation appear in the ‘Sign in with institutional or social ID’ menu?") %> +

    <%= _("If your institution does not integrate with CILogin, then unfortunately you will not be able to sign into the platform with your institutional credentials. However, this does not mean that you cannot access the platform, you can select a social sign in, such as ORCID, or create an account directly on the platform.") %>

    +
    + +
    + <%= _("How do I create a data management plan (DMP) in DMP Assistant?") %> +

    <%= _("After signing in or creating an account, you can create a plan by selecting 'Create plan' under the dashboard or on the top header menu.") %>

    +

    <%= _("Provide a title for your research project, this can be changed at a later time as needed. Select your organization from the drop down menu, if your organization is not listed, you can select ‘Digital Research Alliance of Canada’ to access all national DMP templates. If you think your organization should be listed, reach out to your local Research Data Management contact and they can request an organization to be added to the platform.") %>

    +

    <%= _("After selecting your organization, a list of available templates will appear. Your organization may have local templates to use, but if not, all Alliance templates are available to use. Which template you select will depend on your research project. Note: templates may not completely align with your research project and you may need to select the one that best fits your project. Once a template has been selected, you cannot change the template, you will need to create a new plan, selecting a different template.") %>

    +
    + +
    + <%= _("What is a template?") %> +

    <%= _("A template is a designed set of questions and guidance to help guide researchers to develop a data management plan within DMP Assistant.") %>

    +
    + +
    + <%= _("How to share or add collaborators to a plan?") %> +

    <%= _("There are different ways to share a DMP, whether you want to share your plan internally within your team, externally with other researchers or with staff at your local institution.") %>

    +

    <%= _("To share plans with members of your team so that they can view/edit the DMP, under the ‘Share’ tab when writing your DMP, you can invite who you wish to add and assign them a level of permission (co-owner, editor, read-only). You can add as many collaborators as you would like.") %>

    +

    <%= _("You can add contributors to your plan, but this does NOT provide access to view/edit the DMP, but if you want to name contributors such as a PI (Principal Investigator) on the DMP this would be where you can add their names.") %>

    +
    + +
    + <%= _("What is the Request Feedback feature?") %> +

    <%= _("Some organisations have the Request Feedback feature enabled. If your organisation utilizes this feature, you will see an extra tab while writing your plan. If you see this tab, you can request feedback and a notification will be sent to your local administrators.") %>

    +

    <%= _("If you do not see this tab, you may still be able to receive feedback from expertise at your organisation, however you will need to reach out to your local contact separately.") %>

    +
    + +
    + <%= _("What are Research Outputs?") %> +

    <%= _("Research outputs are optional fields for researchers to complete in addition to the fields of the ‘Write Plan’ component of the DMP. The Research Outputs include fields such as the type of research, title of research, repositories and metadata standards that will be used to share your research, anticipated release date, level of initial access and if applicable and licenses. This is not a required page but may assist researchers in the long term planning of their research project.") %>

    +
    + +
    + <%= _("How do I export my DMP?") %> +

    <%= _("Under the download tab of any DMP, there are a number of downloadable formats for users to download their plans, including csv, html, pdf, text, docx, and json. Users can select what components of their DMP they wish to include in the download.") %>

    +
    + +
    + <%= _("Can I add extra questions or answers to a DMP?") %> +

    <%= _("While users themselves cannot make changes to the template, if additional information is required on a DMP, or there is information you want to add to the DMP but not within the platform, users are suggested to download their plan in a workable format (e.g. text or docx), and make the edits outside of the platform.") %>

    +

    <%= _("If you need further assistance with a DMP, please reach out to your local RDM support contact.") %>

    +
    + +
    + <%= _("How can I change my email?") %> +

    <%= _("Unfortunately an email cannot be changed directly, however you can create a new account with the email address you wish to use. If desired, accounts can be merged, if you wish to do so then contact the help desk and the service team can merge your accounts to ensure you do not lose any of your information.") %>

    +
    + +
    + <%= _("What happens if I move to a different institution?") %> +

    <%= _("If you move to another organization or institution, you can still access your account providing you have access to your email.") %>

    +

    <%= _("If you do not have access to your email, you may still be able to access your DMP Assistant account, however you can create a new account and contact the help desk, where the service team can merge your accounts, so you don’t lose access to your information.") %>

    +

    <%= _("Alternatively, if you don’t wish to create a new account and still have access to your email from the past organization or institution, you can change your profile organization. Go to your name within DMP Assistant and select ‘Edit profile’. From there you can select your new organization and save that information.") %>

    +
    + +
    + <%= _("What is the difference between a contributor and a collaborator?") %> +

    <%= _("Contributors can be anyone you work with, and you can enter any name you want. The contributors will be listed in the plan, but cannot edit or modify the plan in any way.") %>

    +

    <%= _("Collaborators are anyone you wish to share the plan with, and will require the person to create or sign into DMP Assistant to collaborate with you. Collaborators have the ability to comment and edit on the plan as needed.") %>

    +

    <%= _("These functions are different as in many cases not all who are listed as contributors are collaborators, and not all collaborators need to be listed as contributors for all projects depending on their role(s) within the project.") %>

    +
    + +
    + <%= _("I can’t find my DMP?") %> +

    <%= _("If you have signed into your account using single sign on/institutional credentials and are unable to find/view any of your DMPs, you may have created a separate account.") %>

    +

    <%= _("You can either sign in to your account using a different email that may be associated with an account, or you can reach out to the help desk for further assistance. If you unintentionally created an account, we can have the two accounts merged, but this is something that must be done via the service team help desk.") %>

    +
    + +
    + <%= _("I am unable to reset my password, or when I reset my password, an error message saying ‘language must exist’ is displayed?") %> +

    <%= _("If you receive this message, you must contact the help desk. This is a known issue and users may receive this message when their language is improperly stored in the database. Once the language has been reset by the service team, you should be able to sign in normally.") %>

    +
    -

    <%= _("Download plans") %>

    -

    <%= _("From here you can download your plan in various formats. This may be useful if you need to submit your plan as part of a grant application. Choose what format you would like to view/download your plan in and click to download. You can also adjust the formatting (font type, size and margins) for PDF files, which may be helpful if working to page limits.") %>

    \ No newline at end of file diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb index 1c7f52e44a..76f409f940 100644 --- a/app/views/users/refresh_token.js.erb +++ b/app/views/users/refresh_token.js.erb @@ -1,6 +1,8 @@ -var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>'; +// This view is called by both InternalUserAccessTokensController#create (provides @v2_token) +// and UsersController#refresh_token (does not provide @v2_token). +var msg = '<%= @success ? _("Successfully regenerated your API token.") : _("Unable to regenerate your API token.") %>'; -var context = $('#api-token'); -context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>'); +var context = $('#api-tokens'); +context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user, v2_token: @v2_token }) %>'); renderNotice(msg); toggleSpinner(false); diff --git a/config/application.rb b/config/application.rb index 792dd4b0d2..87745f74aa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -103,5 +103,12 @@ class Application < Rails::Application # customized templates. For this reason we are specifying in the # documentation the funder that config.default_funder_id = Rails.application.secrets.default_funder_id.to_i + + # apply application styling to doorkeeper views + config.to_prepare do + Doorkeeper::ApplicationsController.layout "application" + Doorkeeper::AuthorizationsController.layout "application" + Doorkeeper::AuthorizedApplicationsController.layout "application" + end end end diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb index 23b5fcf13c..7e68c20c41 100644 --- a/config/initializers/_dmproadmap.rb +++ b/config/initializers/_dmproadmap.rb @@ -64,6 +64,8 @@ class Application < Rails::Application # Used throughout the system via ApplicationService.application_name config.x.application.name = 'DMP Assistant' + # Name of the internal Doorkeeper OAuth application for v2 API access tokens + config.x.application.internal_oauth_app_name = 'Internal v2 API Client' # Used as the default domain when 'archiving' (aka anonymizing) a user account # for example `jane.doe@uni.edu` becomes `1234@removed_accounts-example.org` config.x.application.archived_accounts_email_suffix = '@removed_accounts-example.org' @@ -74,7 +76,8 @@ class Application < Rails::Application # The link to the API documentation - used in emails about the API config.x.application.api_documentation_urls = { v0: 'https://github.com/DMPRoadmap/roadmap/wiki/API-V0-Documentation', - v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v1-Documentation' + v1: 'https://github.com/DMPRoadmap/roadmap/wiki/API-v1-Documentation', + v2: 'https://github.com/portagenetwork/roadmap/wiki/API-v2-Documentation' } # The links that appear on the home page. Add any number of links config.x.application.welcome_links = [ diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000000..887239f1fd --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + # set the object-relational-model (ORM) + orm :active_record + + # ensure resource owner is authenticated + resource_owner_authenticator do + if request.path == native_oauth_authorization_path + # Deactivate native_oauth_authorization_path (intended for mobile devices) + redirect_to root_path, alert: "You are not authorized to perform this action." + else + # https://doorkeeper.gitbook.io/guides/ruby-on-rails/configuration + current_user || warden.authenticate!(scope: :user) + end + end + + # ensure only super-admins can manage oauth applications + admin_authenticator do |_routes| + redirect_to root_path, alert: "You are not authorized to perform this action." unless current_user&.can_super_admin? + end + + # grant flows enabled + # Authorization Code Grant Flow (ACGF) + grant_flows %w[authorization_code client_credentials] + + # allow for redirect-uri to be blank + # (required for client_credentials apps for org-admins) + allow_blank_redirect_uri true + + # scopes enabled + default_scopes :read + + # ensure client apps cannot ask for scopes outwith those specified here + enforce_configured_scopes + + # set the token endpoint configurations + access_token_expires_in 2.hours + + # enable refresh tokens of duration 90 days + use_refresh_token expiry: 90.days + + # enable ssl requirement for redirect url + # - Allow HTTP in test and development environments + force_ssl_in_redirect_uri !(Rails.env.test? || Rails.env.development?) + + hash_application_secrets + hash_token_secrets +end diff --git a/config/initializers/external_apis/ror.rb b/config/initializers/external_apis/ror.rb index 026a052c5d..caa4d21bce 100644 --- a/config/initializers/external_apis/ror.rb +++ b/config/initializers/external_apis/ror.rb @@ -5,7 +5,7 @@ # the API and to verify that your configuration settings are correct, # please refer to: https://github.com/ror-community/ror-api Rails.configuration.x.ror.landing_page_url = "https://ror.org/" -Rails.configuration.x.ror.api_base_url = "https://api.ror.org/v1/" +Rails.configuration.x.ror.api_base_url = "https://api.ror.org/" Rails.configuration.x.ror.heartbeat_path = "heartbeat" Rails.configuration.x.ror.search_path = "organizations" Rails.configuration.x.ror.max_pages = 2 diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index c8ca0f574c..d7135a5e6e 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -2,25 +2,40 @@ # NB: `req` is a Rack::Request object (basically an env hash with friendly accessor methods) +# Progressive login throttling by IP. +# - Each throttle has its own counter and time window. +# - All matching throttles increment on every request. +# - A request is blocked if any throttle exceeds its limit. +LEVELS = [ + ['lowest', 5, 3.minutes], + ['low', 10, 15.minutes], + ['medium', 20, 2.hours], + ['high', 50, 10.hours], + ['highest', 100, 72.hours] +].freeze + +# All paths that we will apply throttling to +PATHS = ['/users/sign_in', '/users/password'].freeze + # Enable/disable Rack::Attack Rack::Attack.enabled = true # Cache store required to work. Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new # defaults to Rails.cache -# Throttle should send a 429 Error responsec code and display public/429.html +# Throttle should send a 429 Error response code and display public/429.html Rack::Attack.throttled_responder = lambda do |_env| html = ActionView::Base.empty.render(file: 'public/429.html') [429, { 'Content-Type' => 'text/html' }, [html]] end -# Throttle attempts to a particular path. 2 POSTs to /users/password every 30 seconds -Rack::Attack.throttle "password_resets/ip", limit: 2, period: 30.seconds do |req| - req.post? && req.path == "/users/password" && req.ip -end - -# Throttle attempts to a particular path. 4 POSTs to /users/sign_in every 30 seconds -Rack::Attack.throttle "logins/ip", limit: 4, period: 30.seconds do |req| - # Don't apply sign-in rate-limiting to test environment - req.post? && req.path == "/users/sign_in" && req.ip unless Rails.env.test? +unless Rails.env.test? + # Create a throttle for each (path, level) pairing + PATHS.each do |path| + LEVELS.each do |name, limit, period| + Rack::Attack.throttle("#{path} #{name}", limit: limit, period: period) do |req| + req.post? && req.path == path && req.ip + end + end + end end diff --git a/config/locales/.translation_io b/config/locales/.translation_io index 153210ff4c..58a904ba80 100644 --- a/config/locales/.translation_io +++ b/config/locales/.translation_io @@ -1,2 +1,2 @@ --- -timestamp: 1676485914 +timestamp: 1770842278 diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 0000000000..b3c4b2c660 --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,154 @@ +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: + zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.' + one: 'The code_challenge_method must be %{challenge_methods}.' + other: 'The code_challenge_method must be one of %{challenge_methods}.' + 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/localization.en-CA.yml b/config/locales/localization.en-CA.yml index 5182a9f0f5..c176fa139c 100644 --- a/config/locales/localization.en-CA.yml +++ b/config/locales/localization.en-CA.yml @@ -67,10 +67,3 @@ en-CA: default: "%a, %d %b %Y %H:%M:%S %z" long: "%d %B, %Y %H:%M" short: "%d %b %H:%M" - devise: - registrations: - signed_up: Welcome! You have signed up successfully. - sessions: - already_signed_out: Signed out successfully. - signed_in: Signed in successfully. - signed_out: Signed out successfully. diff --git a/config/locales/localization.fr-CA.yml b/config/locales/localization.fr-CA.yml index 2d80ac843e..00513632f1 100644 --- a/config/locales/localization.fr-CA.yml +++ b/config/locales/localization.fr-CA.yml @@ -90,10 +90,3 @@ fr-CA: spree: date_picker: first_day: 0 - devise: - registrations: - signed_up: Bienvenue ! Vous vous êtes enregistré(e) avec succès. - sessions: - already_signed_out: Déconnecté(e). - signed_in: Connecté(e) avec succès. - signed_out: Déconnecté(e) avec succès. diff --git a/config/locales/translation.en-CA.yml b/config/locales/translation.en-CA.yml index f6b002c8bd..9d4b63f736 100644 --- a/config/locales/translation.en-CA.yml +++ b/config/locales/translation.en-CA.yml @@ -128,11 +128,27 @@ en-CA: activerecord: errors: messages: - complexity: "complexity requirement not met." record_invalid: 'Validation failed: %{errors}' restrict_dependent_destroy: has_one: Cannot delete record because a dependent %{record} exists has_many: Cannot delete record because dependent %{record} exist + complexity: complexity requirement not met. + 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. + attributes: + doorkeeper/application: + name: Name + redirect_uri: Redirect URI datetime: distance_in_words: half_a_minute: half a minute @@ -343,3 +359,140 @@ en-CA: previous: "‹ Prev" next: Next › truncate: "…" + 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: Application UID + secret: Secret + secret_hashed: + scopes: Scopes + confidential: Confidential + callback_urls: Callback urls + actions: Actions + 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: + 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: + 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_code_challenge: + 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: + zero: + one: + other: + 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. + 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. + unsupported_response_type: The authorization server does not support this + response type. + unsupported_response_mode: + 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: + 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/translation.fr-CA.yml b/config/locales/translation.fr-CA.yml index 8d72ae013e..54505d4a93 100644 --- a/config/locales/translation.fr-CA.yml +++ b/config/locales/translation.fr-CA.yml @@ -136,13 +136,29 @@ fr-CA: activerecord: errors: messages: - complexity: ' Le mot de passe ne respecte pas les exigences de complexité.' record_invalid: 'La validation a échoué : %{errors}' restrict_dependent_destroy: has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record} dépendant(e) existe has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record} dépendants existent + complexity: " Le mot de passe ne respecte pas les exigences de complexité." + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: ne peut contenir un fragment. + invalid_uri: doit être une URL valide. + unspecified_scheme: doit spécifier un schéma. + relative_uri: doit être une URL absolue. + secured_uri: doit être une URL HTTP/SSL. + forbidden_uri: est interdit par le serveur. + scopes: + not_match_configured: doesn't match configured on the server. + attributes: + doorkeeper/application: + name: Nom + redirect_uri: L'URL de redirection datetime: distance_in_words: half_a_minute: une demi-minute @@ -365,3 +381,148 @@ fr-CA: previous: "‹ précédent(e)" next: "› suivant(e)" truncate: "…" + doorkeeper: + applications: + confirmations: + destroy: Êtes-vous certain? + buttons: + edit: Modifier + destroy: Supprimer + submit: Envoyer + cancel: Annuler + authorize: Autoriser + form: + error: Oups! Vérifier votre formulaire pour des erreurs possibles + help: + confidential: | + L'application sera utilisée quand la confidentialité du secret pourra + être maintenue. Les application mobile native, et les Applications + mono-page ne sont pas considérées comme sûr. + redirect_uri: Utiliser une ligne par URL + 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: Utilisez un espace entre chaque portée. Laissez vide pour utiliser + la portée par defaut + edit: + title: Modifier l'application + index: + title: Vos applications + new: Nouvelle application + name: Nom + callback_url: URL de retour d'appel + confidential: Confidential? + actions: Actions + confidentiality: + 'yes': Oui + 'no': Non + new: + title: Nouvelle application + show: + title: 'Application : %{name}' + application_id: ID de l'application + secret: Secret + secret_hashed: + scopes: Portées + confidential: Confidential + callback_urls: URL du retour d'appel + actions: Actions + not_defined: + authorizations: + buttons: + authorize: Autoriser + deny: Refuser + error: + title: Une erreur est survenue + new: + title: Autorisation requise + prompt: Autorisez %{client_name} à utiliser votre compte? + able_to: Cette application pourra + show: + title: Code d'autorisation + form_post: + title: + authorized_applications: + confirmations: + revoke: Êtes-vous certain? + buttons: + revoke: Annuler + index: + title: Vos applications autorisées + application: Application + created_at: Créé le + date_format: "%Y-%m-%d %H:%M:%S" + pre_authorization: + status: Pre-authorization + errors: + messages: + invalid_request: + unknown: La demande manque un paramètre requis, inclut une valeur de paramètre + non prise en charge, ou est autrement mal formée. + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: Request need to be authorized. Required parameter + for authorizing request is missing or invalid. + invalid_code_challenge: + invalid_redirect_uri: L'URL de redirection n'est pas valide. + unauthorized_client: Le client n'est pas autorisé à effectuer cette demande + à l'aide de cette méthode. + access_denied: Le propriétaire de la ressource ou le serveur d'autorisation + a refusé la demande. + invalid_scope: Le scope demandé n'est pas valide, est inconnu, ou est mal + formé. + invalid_code_challenge_method: + zero: + one: + other: + server_error: Le serveur d'autorisation a rencontré une condition inattendue + qui l'a empêché de remplir la demande. + temporarily_unavailable: Le serveur d'autorisation est actuellement incapable + de traiter la demande à cause d'une surcharge ou d'un entretien temporaire + du serveur. + credential_flow_not_configured: Le flux des identifiants du mot de passe du + propriétaire de la ressource a échoué en raison de Doorkeeper.configure.resource_owner_from_credentials + n'est pas configuré. + resource_owner_authenticator_not_configured: La recherche du propriétaire + de la ressource a échoué en raison de Doorkeeper.configure.resource_owner_authenticator + n'est pas configuré. + admin_authenticator_not_configured: Access to admin panel is forbidden due + to Doorkeeper.configure.admin_authenticator being unconfigured. + unsupported_response_type: Le serveur d'autorisation ne prend pas en charge + ce type de réponse. + unsupported_response_mode: + invalid_client: L'authentification du client a échoué à cause d'un client + inconnu, d'aucune authentification de client incluse, ou d'une méthode d'authentification + non prise en charge. + invalid_grant: Le consentement d'autorisation accordé n'est pas valide, a + expiré, est annulé, ne concorde pas avec l'URL de redirection utilisée dans + la demande d'autorisation, ou a été émis à un autre client. + unsupported_grant_type: Le type de consentement d'autorisation n'est pas pris + en charge par le serveur d'autorisation. + invalid_token: + revoked: Le jeton d'accès a été annulé + expired: Le jeton d'accès a expiré + unknown: Le jeton d'accès n'est pas valide + revoke: + unauthorized: Vous n'êtes pas autorisé à révoquer ce jeton + forbidden_token: + missing_scope: + flash: + applications: + create: + notice: Application créée. + destroy: + notice: Application supprimée. + update: + notice: Application mise à jour. + authorized_applications: + destroy: + notice: Application annulée. + layouts: + admin: + title: Doorkeeper + nav: + oauth2_provider: Fournisseur OAuth2 + applications: Applications + home: Home + application: + title: Autorisation OAuth requise diff --git a/config/routes.rb b/config/routes.rb index daf0d3e1a7..0991ecb08d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,7 @@ # rubocop:disable Metrics/BlockLength Rails.application.routes.draw do + use_doorkeeper # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html devise_for(:users, controllers: { @@ -208,6 +209,15 @@ namespace :ca_dashboard do resources :stats, only: [:index] end + + namespace :v2 do + get :heartbeat, controller: :base_api + get :me, controller: :base_api + + resources :plans, only: %i[index show] + resources :templates, only: :index + resource :internal_user_access_token, only: :create, defaults: { format: :js } + end end namespace :paginable do diff --git a/db/migrate/20260211183556_create_doorkeeper_tables.rb b/db/migrate/20260211183556_create_doorkeeper_tables.rb new file mode 100644 index 0000000000..4dd60d10bf --- /dev/null +++ b/db/migrate/20260211183556_create_doorkeeper_tables.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class CreateDoorkeeperTables < ActiveRecord::Migration[6.1] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + # Remove `null: false` or use conditional constraint if you are planning to use public clients. + t.string :secret, null: false + + # Remove `null: false` if you are planning to use grant flows + # that doesn't require redirect URI to be used during authorization + # like Client Credentials flow or Resource Owner Password. + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.boolean :confidential, null: false, default: true + t.timestamps null: false + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.references :resource_owner, null: false + t.references :application, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.datetime :created_at, null: false + t.datetime :revoked_at + end + + add_index :oauth_access_grants, :token, unique: true + add_foreign_key( + :oauth_access_grants, + :oauth_applications, + column: :application_id + ) + + create_table :oauth_access_tokens do |t| + t.references :resource_owner, index: true + + # Remove `null: false` if you are planning to use Password + # Credentials Grant flow that doesn't require an application. + t.references :application, null: false + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + 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 + + # The authorization server MAY issue a new refresh token, in which case + # *the client MUST discard the old refresh token* and replace it with the + # new refresh token. The authorization server MAY revoke the old + # refresh token after issuing a new refresh token to the client. + # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + # + # Doorkeeper implementation: if there is a `previous_refresh_token` column, + # refresh tokens will be revoked after a related access token is used. + # If there is no `previous_refresh_token` column, previous tokens are + # revoked as soon as a new access token is created. + # + # Comment out this line if you want refresh tokens to be instantly + # revoked after use. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :oauth_access_tokens, :token, unique: true + + # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592 + if ActiveRecord::Base.connection.adapter_name == "SQLServer" + execute <<~SQL.squish + CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token) + WHERE refresh_token IS NOT NULL + SQL + else + add_index :oauth_access_tokens, :refresh_token, unique: true + end + + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + + # Uncomment below to ensure a valid reference to the resource owner's table + add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id + add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id + end +end diff --git a/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb b/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb new file mode 100644 index 0000000000..1c67c7cf1d --- /dev/null +++ b/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb @@ -0,0 +1,9 @@ +class AllowNullRedirectUriInOauthApplications < ActiveRecord::Migration[6.1] + def change + # We currently have `allow_blank_redirect_uri true` in + # `config/initializers/doorkeeper.rb`. Removing the NOT NULL constraint + # allows us to save OAuthApplications with blank redirect_uri values + # https://github.com/doorkeeper-gem/doorkeeper/wiki/Allow-blank-redirect-URI-for-Applications + change_column_null :oauth_applications, :redirect_uri, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 466b1fbda9..fdd8b29448 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.define(version: 2024_08_20_190548) do +ActiveRecord::Schema.define(version: 2026_02_19_200258) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -315,6 +315,48 @@ t.boolean "enabled", default: true end + create_table "oauth_access_grants", 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", force: :cascade do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + 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", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri" + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "option_warnings", id: :serial, force: :cascade do |t| t.integer "organisation_id" t.integer "option_id" @@ -879,6 +921,10 @@ add_foreign_key "notes", "users" add_foreign_key "notification_acknowledgements", "notifications" add_foreign_key "notification_acknowledgements", "users" + 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 "org_token_permissions", "orgs" add_foreign_key "org_token_permissions", "token_permission_types" add_foreign_key "orgs", "languages" diff --git a/entrypoint.sh b/entrypoint.sh index 1bffe45d78..2c86d4e11a 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -12,6 +12,10 @@ done echo "Database is ready." +echo "==> Running database migrations..." +bundle exec rails db:migrate + + echo "==> Executing translation:sync..." if ! bundle exec rake translation:sync; then echo "ERROR: translation:sync failed, continuing anyway." diff --git a/lib/tasks/doorkeeper.rake b/lib/tasks/doorkeeper.rake new file mode 100644 index 0000000000..60b279eb71 --- /dev/null +++ b/lib/tasks/doorkeeper.rake @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +namespace :doorkeeper do + desc 'Ensure internal OAuth application exists' + task ensure_internal_app: :environment do + app = Doorkeeper::Application.find_or_create_by!( + name: Rails.application.config.x.application.internal_oauth_app_name + ) do |a| + a.scopes = 'read' + a.confidential = true + end + + puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})" + end +end diff --git a/lib/tasks/orgs.rake b/lib/tasks/orgs.rake index 8db8c6858d..7ba054ccb0 100644 --- a/lib/tasks/orgs.rake +++ b/lib/tasks/orgs.rake @@ -5,4 +5,12 @@ namespace :orgs do task update_ror_data: :environment do Orgs::UpdateRorService.run end + + desc 'Cleans up unmanaged orgs having 1 or more users. + For each such org: + - Reassign associated users and plans to the "default org" + - Attempt deletion of the org' + task cleanup_unmanaged_orgs_with_users: :environment do + Orgs::CleanupUnmanagedOrgsWithUsersService.run + end end diff --git a/package.json b/package.json index ea4a3f4273..2b961b6459 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "core-js": "^3.6.5", "eslint": "^8.18.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.32.0", "eslint-webpack-plugin": "^2.6.0", "jquery": "^3.5.1", "jquery-ui": "^1.13.2", @@ -67,7 +67,7 @@ "devDependencies": { "eslint": "^8.18.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.26.0", + "eslint-plugin-import": "^2.32.0", "eslint-webpack-plugin": "^2.6.0", "jasmine": "^4.2.1", "jasmine-core": "^4.2.0", diff --git a/spec/factories/identifier_schemes.rb b/spec/factories/identifier_schemes.rb index d9e3de64bb..03b1e38726 100644 --- a/spec/factories/identifier_schemes.rb +++ b/spec/factories/identifier_schemes.rb @@ -38,5 +38,19 @@ description { 'CILogon' } identifier_prefix { 'https://www.cilogon.org/' } end + + %i[ + authentication + orgs + plans + users + contributors + identification + research_outputs + ].each do |context| + trait :"for_#{context}" do + add_attribute(:"for_#{context}") { true } + end + end end end diff --git a/spec/factories/oauth_access_grants.rb b/spec/factories/oauth_access_grants.rb new file mode 100644 index 0000000000..2cd8dc8875 --- /dev/null +++ b/spec/factories/oauth_access_grants.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_grants +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# +# Indexes +# +# index_oauth_access_grants_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_grant, class: 'doorkeeper/access_grant' do + token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + redirect_uri { Faker::Internet.url } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb new file mode 100644 index 0000000000..aa94d6b373 --- /dev/null +++ b/spec/factories/oauth_access_tokens.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_tokens +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# refresh_token :string +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# previous_refresh_token :string +# +# Indexes +# +# index_oauth_access_tokens_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_token, class: 'doorkeeper/access_token' do + token { SecureRandom.uuid } + refresh_token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb new file mode 100644 index 0000000000..469dd66269 --- /dev/null +++ b/spec/factories/oauth_applications.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_application +# +# id: :integer +# name: :string +# uid: :string +# secret: :string +# redirect_uri: :text +# scopes: :string +# confidential: :boolean +# created_at: :datetime +# updated_at: :datetime + +FactoryBot.define do + factory :oauth_application, class: 'doorkeeper/application' do + name { Faker::Lorem.unique.word } + uid { SecureRandom.uuid } + secret { SecureRandom.uuid } + redirect_uri { "https://#{Faker::Internet.unique.domain_name}/callback" } + scopes { 'read' } + end +end diff --git a/spec/factories/research_domains.rb b/spec/factories/research_domains.rb index 7d4017f924..23049903be 100644 --- a/spec/factories/research_domains.rb +++ b/spec/factories/research_domains.rb @@ -23,6 +23,5 @@ factory :research_domain do identifier { SecureRandom.uuid } label { Faker::Lorem.unique.word } - uri { Faker::Internet.url } end end diff --git a/spec/features/locales_spec.rb b/spec/features/locales_spec.rb index 0153fc8a2d..21ac68f48d 100644 --- a/spec/features/locales_spec.rb +++ b/spec/features/locales_spec.rb @@ -50,7 +50,7 @@ context 'when new locale has no region' do scenario 'user changes their locale' do skip 'We are now expecting locales to have region' - create_plan_text = 'Créer des plans' + create_plan_text = 'Créer un plan' click_link 'Language' expect(current_path).to eql(plans_path) expect(page).not_to have_text(create_plan_text) @@ -63,7 +63,7 @@ context 'when new locale has region' do scenario 'user changes their locale' do - create_plan_text = 'Créer des plans' + create_plan_text = 'Créer un plan' click_link 'Language' expect(current_path).to eql(plans_path) expect(page).not_to have_text(create_plan_text) diff --git a/spec/features/plans_spec.rb b/spec/features/plans_spec.rb index 8696f9c0ba..2da6591eab 100644 --- a/spec/features/plans_spec.rb +++ b/spec/features/plans_spec.rb @@ -27,7 +27,7 @@ end it 'User creates a new Plan', :js do - click_link 'Create plan' + find('#create-plan-link').click fill_in :plan_title, with: 'My test plan' choose_suggestion('plan_org_org_name', @org) @@ -53,7 +53,7 @@ end it 'displays template dropdown headers and expands more templates', :js do - click_link 'Create plan' + find('#create-plan-link').click fill_in :plan_title, with: 'My test plan' choose_suggestion('plan_org_org_name', @org) diff --git a/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb new file mode 100644 index 0000000000..3a59becfb3 --- /dev/null +++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::InternalUserAccessTokensController do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + describe 'POST #create' do + def post_create_token + post api_v2_internal_user_access_token_path + end + + context 'when user is not authenticated' do + # In production, CSRF protection would reject the request with a 422 error + # before it reaches Pundit. However, RSpec bypasses CSRF checks, so this + # test verifies that Pundit raises NotDefinedError when authorize is called + # with nil. This error won't occur in production due to CSRF protection. + it 'raises Pundit::NotDefinedError and does not create a token' do + expect do + expect do + post_create_token + end.to raise_error(Pundit::NotDefinedError) + end.not_to change { Doorkeeper::AccessToken.count } + end + end + + context 'when user is authenticated' do + before { sign_in(user) } + + it 'rotates the user token' do + post_create_token + + expect(response).to have_http_status(:ok) + end + + it 'creates a new token' do + expect do + post_create_token + end.to change { Doorkeeper::AccessToken.count }.by(1) + end + + it 'assigns the plaintext token' do + post_create_token + + expect(assigns(:v2_token)).to be_a(String) + expect(assigns(:v2_token)).not_to be_blank + end + + it 'renders the refresh_token template' do + post_create_token + + expect(response).to render_template('users/refresh_token') + end + + context 'when a token already exists' do + let!(:old_token) do + create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read') + end + + it 'revokes the old token' do + post_create_token + + old_token.reload + expect(old_token.revoked_at).not_to be_nil + end + + it 'creates a new token' do + post_create_token + + new_token = assigns(:token) + expect(new_token).not_to eq(old_token) + end + end + end + + context 'when the internal OAuth application is missing' do + before do + sign_in(user) + oauth_app.destroy + end + + it 'raises a StandardError' do + expect do + post_create_token + end.to raise_error(StandardError, /not found/) + end + end + end +end diff --git a/spec/requests/api/v2/plans_controller_spec.rb b/spec/requests/api/v2/plans_controller_spec.rb new file mode 100644 index 0000000000..dc528ca179 --- /dev/null +++ b/spec/requests/api/v2/plans_controller_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::PlansController do + include ApiHelper + include Mocks::ApiV2JsonSamples + include Webmocks + include IdentifierHelper + + context 'OAuth (authorization_code grant type) — on behalf of a user' do + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).plaintext_token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_plans_json_response + get(api_v2_plans_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/plans/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/plans (index)' do + context 'an invalid API token is included' do + it 'returns a 401 and the expected Oauth 2.0 headers' do + # Swap actual token with a random string + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_plans_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + let(:json) { fetch_plans_json_response } + it 'returns a 200 and the expected response body' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Application and source are present and sensible + expect(json[:application]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/plans') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Caller is included + expect(json[:caller]).to eq(@client.name) + end + + it 'returns an empty array if no plans are available' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + end + + it 'returns the expected plans' do + # See `app/policies/api/v2/plans_policy.rb for plans included/excluded via `GET api/v2/plans` + + # Create the included plans + included_plans = [create(:plan, org: @user.org), create(:plan)] + included_plans[0].add_user!(@user.id, :creator) + # Add multiple roles for testing (ensure duplicate plans will not returned) + included_plans[1].add_user!(@user.id, :editor) + included_plans[1].add_user!(@user.id, :commenter) + + # Created the excluded plans + create(:plan, :creator, org: @user.org) + inactive_plan = create(:plan, :creator) + inactive_plan.add_user!(@user.id, :editor) + Role.where(plan_id: inactive_plan.id, user_id: @user.id).update(active: false) + + expect(json[:items].length).to be(included_plans.length) + + # Api::V2::PlanPresenter.identifier uses api_v2_plan_url(@plan) to set the "identifier". + # That url is constructed using `request.host` / "www.example.com" + # api_v2_plan_url(@plan) within this test will construct the url via + # default_url_options[:host] / "example.org" + # Because the urls are misaligned, we will only compare the paths here. + # TODO: Consider aligning default_url_options[:host] (in test.rb) with `request.host` + returned_identifiers = json[:items].map { |item| item[:dmp][:dmp_id][:identifier] } + returned_paths = returned_identifiers.map { |url| URI(url).path } + expected_paths = included_plans.map { |plan| api_v2_plan_path(plan) } + expect(returned_paths).to eq(expected_paths) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + + create_list(:plan, 11, :publicly_visible) do |plan| + plan.add_user!(@user.id, :commenter) + end + json = fetch_plans_json_response + + test_paging(json: json, headers: @headers) + + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end + end +end diff --git a/spec/requests/api/v2/templates_controller_spec.rb b/spec/requests/api/v2/templates_controller_spec.rb new file mode 100644 index 0000000000..e84825ae51 --- /dev/null +++ b/spec/requests/api/v2/templates_controller_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::TemplatesController do + include ApiHelper + + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).plaintext_token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_templates_json_response + get(api_v2_templates_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/templates (index)' do + context 'an invalid API token is included' do + it 'returns 401 if the token is invalid' do + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + it 'returns a 200 and the expected response body' do + json = fetch_templates_json_response + + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Application and source are present and sensible + expect(json[:application]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/templates') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Caller is included + expect(json[:caller]).to eq(@client.name) + end + + it 'returns an empty array if no templates are available' do + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('200') + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + + json = JSON.parse(response.body).with_indifferent_access + expect(json[:items].empty?).to be(true) + expect(json[:errors].nil?).to be(true) + end + + it 'returns the expected templates' do + # See `app/policies/api/v2/templates_policy.rb for templates included/excluded via `GET api/v2/templates` + + # All included templates must be published and are either: + # - 1) organisationally_visible and template.org_id == user.org_id + # - 2) publicly_visible and customization of == nil + + public_template = create(:template, :publicly_visible, published: true) + + included_templates = [ + public_template, + create(:template, :organisationally_visible, published: true, org: @user.org) + ] + + # excluded_templates + # unpublished template + create(:template, :publicly_visible, published: false, org: @user.org) + # organisationally_visible and template.org_id != user.org_id + create(:template, :organisationally_visible, published: true) + # publicly_visible and customization of != nil + create(:template, :publicly_visible, published: true, customization_of: public_template.family_id) + + json = fetch_templates_json_response + + expect(json[:items].length).to be(2) + template_ids = json[:items].map { |item| item[:dmp_template][:template_id][:identifier] } + expect(template_ids).to match_array(included_templates.map { |t| t.id.to_s }) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + create_list(:template, 11, visibility: 1, published: true) + get(api_v2_templates_path, headers: @headers) + + test_paging(json: JSON.parse(response.body), headers: @headers) + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end +end diff --git a/spec/services/api/v2/internal_user_access_token_service_spec.rb b/spec/services/api/v2/internal_user_access_token_service_spec.rb new file mode 100644 index 0000000000..58d3f6163e --- /dev/null +++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::InternalUserAccessTokenService do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + def create_internal_user_access_token + create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read') + end + + describe '#rotate!' do + def rotate_token_expectations(plaintext_token, old_token = nil) # rubocop:disable Metrics/AbcSize + # Doorkeeper hashes token via Digest::SHA256 + hashed = Digest::SHA256.hexdigest(plaintext_token) + new_token = Doorkeeper::AccessToken.find_by!(token: hashed) + expect(new_token).to be_present + expect(new_token.resource_owner_id).to eq(user.id) + expect(new_token.revoked_at).to be_nil + expect(new_token.scopes.to_s).to include('read') + # Verify new_token expires in 24 hours + expected_expires_at = new_token.created_at + 24.hours + actual_expires_at = new_token.created_at + new_token.expires_in.seconds + expect(actual_expires_at).to be_within(1.second).of(expected_expires_at) + return unless old_token + + # Verify old_token was revoked shortly before new_token was created + expect(old_token.revoked_at).to be <= new_token.created_at + expect(old_token.revoked_at).to be_within(1.second).of(new_token.created_at) + end + + shared_examples 'token rotation' do |has_old_token| + it "#{if has_old_token + 'revokes the old token and creates a new one' + else + 'creates a new token' + end} + (returns plaintext)" do + plaintext_token = nil + # Ensure .rotate!(user) creates a new AccessToken db entry for user + expect { plaintext_token = described_class.rotate!(user) } + .to change { Doorkeeper::AccessToken.where(resource_owner_id: user.id).count } + .by(1) + if has_old_token + old_token.reload + rotate_token_expectations(plaintext_token, old_token) + else + rotate_token_expectations(plaintext_token) + end + end + end + + context 'when a token already exists' do + let!(:old_token) { create_internal_user_access_token } + include_examples 'token rotation', true + end + + context 'when no token exists' do + include_examples 'token rotation', false + end + end + + describe '#application_present?' do + context 'when the app exists' do + it 'returns true' do + expect(described_class.application_present?).to be true + end + end + + context 'when the app does not exist' do + before { oauth_app.destroy } + + it 'returns false' do + expect(described_class.application_present?).to be false + end + end + end +end diff --git a/spec/services/external_apis/ror_service_spec.rb b/spec/services/external_apis/ror_service_spec.rb index 42c1f417c2..7001d9ba0c 100644 --- a/spec/services/external_apis/ror_service_spec.rb +++ b/spec/services/external_apis/ror_service_spec.rb @@ -71,28 +71,48 @@ items: [ { id: 'https://ror.org/1234567890', - name: 'Example University', + names: [ + { value: 'Example University', types: ['ror_display'] }, + { value: 'EU', types: ['acronym'] } + ], types: ['Education'], - links: ['http://example.edu/'], - aliases: ['Example'], - acronyms: ['EU'], + links: [ + { type: 'website', value: 'http://example.edu/' } + ], status: 'active', - country: { country_name: 'United States', country_code: 'US' }, - external_ids: { - GRID: { preferred: 'grid.12345.1', all: 'grid.12345.1' } - } + locations: [ + { + geonames_id: 123_456, + geonames_details: { + country_name: 'United States', + country_code: 'US' + } + } + ], + external_ids: [ + { type: 'grid', preferred: 'grid.12345.1', all: ['grid.12345.1'] } + ] }, { id: 'https://ror.org/0987654321', - name: 'Universidade de Example', + names: [ + { value: 'Universidade de Example', types: ['ror_display'] }, + { value: 'EU', types: ['acronym'] } + ], types: ['Education'], links: [], - aliases: ['Example'], - acronyms: ['EU'], status: 'active', - country: { country_name: 'Mexico', country_code: 'MX' }, - external_ids: { - GRID: { preferred: 'grid.98765.8', all: 'grid.98765.8' } - } + locations: [ + { + geonames_id: 12_345, + geonames_details: { + country_name: 'Mexico', + country_code: 'MX' + } + } + ], + external_ids: [ + { type: 'fundref', preferred: 'fundref.12345', all: ['fundref.12345'] } + ] } ] } @@ -108,19 +128,11 @@ end it 'includes the website in the name (if available)' do - expected = { - id: 'https://ror.org/1234567890', - name: 'Example University (example.edu)' - } - expect(@orgs.map { |i| i[:name] }.include?(expected[:name])).to eql(true) + expect(@orgs.map { |i| i[:name] }).to include('Example University (example.edu)') end it 'includes the country in the name (if no website is available)' do - expected = { - id: 'https://ror.org/0987654321', - name: 'Universidade de Example (Mexico)' - } - expect(@orgs.map { |i| i[:name] }.include?(expected[:name])).to eql(true) + expect(@orgs.map { |i| i[:name] }).to include('Universidade de Example (Mexico)') end end end @@ -133,8 +145,16 @@ time_taken: 5, items: [{ id: Faker::Internet.url, - name: Faker::Lorem.word, - country: { country_name: Faker::Lorem.word } + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: Faker::Lorem.word, + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ] }] } @term = Faker::Lorem.word @@ -207,8 +227,17 @@ items = Array.new(4).map do { id: Faker::Internet.unique.url, - name: Faker::Lorem.word, - country: { country_name: Faker::Lorem.word } + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: Faker::Lorem.word, + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ], + external_ids: [] } end results1 = { number_of_results: 4, items: items } @@ -226,8 +255,17 @@ items = Array.new(7).map do { id: Faker::Internet.unique.url, - name: Faker::Lorem.word, - country: { country_name: Faker::Lorem.word } + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: Faker::Lorem.word, + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ], + external_ids: [] } end results1 = { number_of_results: 7, items: items[0..4] } @@ -248,8 +286,17 @@ items = Array.new(12).map do { id: Faker::Internet.unique.url, - name: Faker::Lorem.word, - country: { country_name: Faker::Lorem.word } + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: Faker::Lorem.word, + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ], + external_ids: [] } end results1 = { number_of_results: 12, items: items[0..4] } @@ -272,52 +319,102 @@ it 'returns an empty array if there are no items' do expect(described_class.send(:parse_results, json: nil)).to eql([]) end - it 'ignores items with no name or id' do + + it 'ignores items with no name or id and logs an error' do + Rails.logger.expects(:error).at_least(1) + + location = { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: 'Nowhere', + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + json = { items: [ - { id: Faker::Internet.url, name: Faker::Lorem.word }, - { id: Faker::Internet.url }, - { name: Faker::Lorem.word } + { id: Faker::Internet.url, + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + external_ids: [], locations: [location] }, + { names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + external_ids: [], locations: [location] } ] }.to_json + items = described_class.send(:parse_results, json: JSON.parse(json)) expect(items.length).to eql(1) end + it 'returns the correct number of results' do + location = { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: 'Nowhere', + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + json = { items: [ - { id: Faker::Internet.url, name: Faker::Lorem.word }, - { id: Faker::Internet.url, name: Faker::Lorem.word } + { id: Faker::Internet.url, + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + external_ids: [], locations: [location] }, + { id: Faker::Internet.url, + names: [{ types: ['ror_display'], value: Faker::Lorem.word }, { value: 'EU', types: ['acronym'] }], + external_ids: [], locations: [location] } ] }.to_json + items = described_class.send(:parse_results, json: JSON.parse(json)) expect(items.length).to eql(2) end end describe '#org_name' do - it 'returns nil if there is no name' do - json = { country: { country_name: 'Nowhere' } }.to_json - expect(described_class.send(:org_name, item: JSON.parse(json))).to eql('') - end it 'properly appends the website if available' do json = { - name: 'Example College', - links: ['https://example.edu'], - country: { country_name: 'Nowhere' } + names: [{ types: ['ror_display'], value: 'Example College' }, { value: 'EU', types: ['acronym'] }], + links: [{ 'type' => 'website', 'value' => 'https://example.edu' }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: 'Nowhere', + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ] }.to_json expected = 'Example College (example.edu)' expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) end + it 'properly appends the country if available and no website is available' do json = { - name: 'Example College', - country: { country_name: 'Nowhere' } + names: [{ types: ['ror_display'], value: 'Example College' }, { value: 'EU', types: ['acronym'] }], + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: 'Nowhere', + country_code: Faker::Lorem.characters(number: 2).upcase + } + } + ] }.to_json expected = 'Example College (Nowhere)' expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) end + it 'properly handles an item with no website or country' do json = { - name: 'Example College', + names: [{ types: ['ror_display'], value: 'Example College' }, { value: 'EU', types: ['acronym'] }], links: [], - country: {} + locations: [ + { + geonames_id: Faker::Number.number(digits: 6), + geonames_details: { + country_name: '', + country_code: '' + } + } + ] }.to_json expected = 'Example College' expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) @@ -329,15 +426,14 @@ item = JSON.parse({ links: nil }.to_json) expect(described_class.send(:org_website, item: item)).to eql(nil) end - it 'returns nil if the item is nil' do - expect(described_class.send(:org_website, item: nil)).to eql(nil) - end + it 'returns the domain only' do - item = JSON.parse({ links: ['https://example.org/path?a=b'] }.to_json) + item = JSON.parse({ links: [{ 'type' => 'website', 'value' => 'https://example.org/path?a=b' }] }.to_json) expect(described_class.send(:org_website, item: item)).to eql('example.org') end + it 'removes the www prefix' do - item = JSON.parse({ links: ['www.example.org'] }.to_json) + item = JSON.parse({ links: [{ 'type' => 'website', 'value' => 'www.example.org' }] }.to_json) expect(described_class.send(:org_website, item: item)).to eql('example.org') end end @@ -346,22 +442,32 @@ before(:each) do @hash = { external_ids: {} } end + it 'returns a blank if no external_ids are present' do json = JSON.parse(@hash.to_json) expect(described_class.send(:fundref_id, item: json)).to eql('') end + it 'returns a blank if no FundRef ids are present' do - @hash['external_ids'] = { FundRef: {} } + @hash['external_ids'] = [ + { 'type' => 'fundref', 'preferred' => '' } + ] json = JSON.parse(@hash.to_json) expect(described_class.send(:fundref_id, item: json)).to eql('') end + it 'returns the preferred id when specified' do - @hash['external_ids'] = { FundRef: { preferred: '1', all: %w[2 1] } } + @hash['external_ids'] = [ + { 'type' => 'fundref', 'preferred' => '1', 'all' => %w[2 1] } + ] json = JSON.parse(@hash.to_json) expect(described_class.send(:fundref_id, item: json)).to eql('1') end - it 'returns the firstid if no preferred is specified' do - @hash['external_ids'] = { FundRef: { preferred: nil, all: %w[2 1] } } + + it 'returns the first id if no preferred is specified' do + @hash['external_ids'] = [ + { 'type' => 'fundref', 'preferred' => nil, 'all' => %w[2 1] } + ] json = JSON.parse(@hash.to_json) expect(described_class.send(:fundref_id, item: json)).to eql('2') end diff --git a/spec/support/helpers/api.rb b/spec/support/helpers/api.rb index c87931c12e..2a2398dfe7 100644 --- a/spec/support/helpers/api.rb +++ b/spec/support/helpers/api.rb @@ -18,4 +18,44 @@ def mock_authorization_for_user(user: nil) Api::V1::BaseApiController.any_instance.stubs(:authorize_request).returns(true) Api::V1::BaseApiController.any_instance.stubs(:client).returns(user) end + + # API V2+ - Oauth authorization_code grant flow (on behalf of a user) + def mock_authorization_code_token(oauth_application: create(:oauth_application), user: create(:user), + scopes: 'read') + create(:oauth_access_grant, application_id: oauth_application.id, resource_owner_id: user.id, scopes: scopes) + create(:oauth_access_token, application: oauth_application, resource_owner_id: user.id, scopes: scopes) + end + + # Tests the standard pagination functionality + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def test_paging(json: {}, headers: {}) + json = json.with_indifferent_access + original = json[:items].first + if json[:next].present? + # Move to the next page + get(json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:prev].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + # Move back to previous page + get(next_json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:items].first).to eql(original) + elsif json[:prev].present? + get(json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:next].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + get(prev_json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:items].first).to eql(original) + else + raise StandardError, 'Expected to test API pagination but there are not enough items!' + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end diff --git a/spec/support/mocks/api_v2_json_samples.rb b/spec/support/mocks/api_v2_json_samples.rb new file mode 100644 index 0000000000..aaec320811 --- /dev/null +++ b/spec/support/mocks/api_v2_json_samples.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +# Mock JSON submissions +module Mocks + # Disabling rubocop checks here since its basically just large hashes and + # would be difficult to read if broken up into multiple smaller functions. + # One option might be to store them as .json files and then just load them here + # but we would lose the use of Faker + + # rubocop:disable Metrics/ModuleLength, Metrics/MethodLength + module ApiV2JsonSamples + ROLES = %w[Investigation Project_administration Data_curation].freeze + + def mock_identifier_schemes + create(:identifier_scheme, name: 'ror') + create(:identifier_scheme, name: 'fundref') + create(:identifier_scheme, name: 'orcid') + create(:identifier_scheme, name: 'grant') + end + + def minimal_update_json + { + total_items: 1, + items: [ + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + dmp_id: { + type: 'doi', + identifier: SecureRandom.uuid + } + } + } + ] + }.to_json + end + + def minimal_create_json + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + extension: [ + "#{ApplicationService.application_name.split('-').first}": { + template: { + id: Template.last.id, + title: Faker::Lorem.sentence + } + } + ] + } + }.to_json + end + + # rubocop:disable Metrics/AbcSize + def complete_create_json(client: nil) + template = create(:template, :published, :publicly_visible) + lang = Language.all.pluck(:abbreviation).sample || 'en-UK' + ror_scheme = IdentifierScheme.find_or_create_by(name: 'ror') + fundref_scheme = IdentifierScheme.find_or_create_by(name: 'fundref') + ror = create(:identifier, identifiable: create(:org), identifier_scheme: ror_scheme) + fundref = create(:identifier, identifiable: create(:org), identifier_scheme: fundref_scheme) + + contact = { + name: [ + Faker::TvShows::Simpsons.character.split.first, + Faker::TvShows::Simpsons.character.split.last + ].join(' '), + email: Faker::Internet.email, + id: SecureRandom.uuid + } + + { + dmp: { + created: 3.months.ago.to_formatted_s(:iso8601), + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + language: Api::V1::LanguagePresenter.three_char_code(lang: lang), + ethical_issues_exist: %w[yes no unknown].sample, + ethical_issues_description: Faker::Lorem.paragraph, + ethical_issues_report: Faker::Internet.url, + dmp_id: { + type: client.present? ? client.name.downcase : 'other', + identifier: SecureRandom.uuid + }, + contact: { + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + region: Faker::Space.planet, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contact_id: { + type: 'orcid', + identifier: contact[:id] + } + }, + contributor: [{ + role: [ + 'http://credit.niso.org/contributor-roles/project-administration', + 'http://credit.niso.org/contributor-roles/investigation', + 'other' + ], + name: Faker::Movies::StarWars.character, + mbox: Faker::Internet.email, + affiliation: { + name: Faker::Movies::StarWars.planet, + abbreviation: Faker::Lorem.word.upcase, + affiliation_id: { + type: 'ror', + identifier: SecureRandom.uuid + } + }, + contributor_id: { + type: 'orcid', + identifier: SecureRandom.uuid + } + }, { + role: [ + 'http://credit.niso.org/contributor-roles/investigation' + ], + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contributor_id: { + type: 'orcid', + identifier: contact[:id] + } + }], + project: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + start: 3.months.from_now.to_formatted_s(:iso8601), + end: 2.years.from_now.to_formatted_s(:iso8601), + funding: [{ + name: fundref.identifiable.name, + funder_id: { + type: 'fundref', + identifier: fundref.value + }, + grant_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + dmproadmap_funding_opportunity_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + funding_status: %w[planned rejected granted].sample + }] + }], + dataset: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + personal_data: %w[yes no unknown].sample, + sensitive_data: %w[yes no unknown].sample, + issued: 6.months.from_now.to_formatted_s(:iso8601), + dataset_id: { + type: 'url', + identifier: Faker::Internet.url + }, + distribution: [{ + title: Faker::Lorem.sentence, + byte_size: Faker::Number.number(digits: 6), + data_access: %w[open embargoed restricted closed].sample, + host: { + title: Faker::Company.name, + description: Faker::Lorem.paragraph, + url: Faker::Internet.url, + dmproadmap_host_id: { + type: 'url', + identifier: Faker::Internet.url + } + }, + license: [ + { + license_ref: 'http://spdx.org/licenses/CC0-1.0.json', + start_date: 6.months.from_now.to_formatted_s(:iso8601) + } + ] + }] + }], + dmproadmap_template: { id: template.family_id, title: template.title } + } + }.to_json + end + # rubocop:enable Metrics/AbcSize + end + # rubocop:enable Metrics/ModuleLength, Metrics/MethodLength +end diff --git a/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb new file mode 100644 index 0000000000..2758d51d22 --- /dev/null +++ b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/_standard_response.json.jbuilder' do + before do + @application = Faker::Lorem.word + # @caller = Faker::Lorem.word + @url = Faker::Internet.url + @code = 200 + + assign :application, @application + # assign :caller, @caller + + @response = OpenStruct.new(status: @code) + @request = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'standard response items - Also the same as: GET /heartbeat' do + before do + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :code' do + expect(@json[:code]).to eql(@code) + end + + it 'includes the :message' do + expect(@json[:message]).to eql(Rack::Utils::HTTP_STATUS_CODES[@code]) + end + + it 'includes the :time' do + expect(@json[:time].present?).to be(true) + end + + it ':time is in UTC format' do + expect(Date.parse(@json[:time]).is_a?(Date)).to be(true) + end + + # it 'includes the :caller' do + # expect(@json[:caller]).to eql(@caller) + # end + + it 'includes the :source' do + expect(@json[:source].include?(@url)).to be(true) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(0) + end + end + + context 'responses with pagination' do + describe 'On the 1st page and there is only one page' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 3 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(3) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:prev].present?).to be(false) + end + end + + describe 'On the 1st page and there multiple pages' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 4 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(4) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the 2nd page and there more than 2 pages' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 7 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(7) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the last page' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 5 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(5) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(false) + end + end + end +end diff --git a/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c2652decaf --- /dev/null +++ b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/contributors/_show.json.jbuilder' do + before do + @plan = create(:plan) + scheme = create(:identifier_scheme, name: 'orcid') + @contact = create(:contributor, org: create(:org), plan: @plan, roles_count: 0, + data_curation: true) + @ident = create(:identifier, identifiable: @contact, value: Faker::Lorem.word, + identifier_scheme: scheme) + @contact.reload + end + + describe 'includes all of the Contributor attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'includes the :role' do + expect(@json[:role].first.ends_with?('data-curation')).to be(true) + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contributor_id' do + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contributor_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + end + + describe 'includes all of the Contact attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact, + is_contact: true } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'does NOT include the :role' do + expect(@json[:role]).to be_nil + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contact_id' do + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contact_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..fc1b7d4e7b --- /dev/null +++ b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/datasets/_show.json.jbuilder' do + context ':output is a ResearchOutput' do + describe 'includes all of the dataset attributes' do + before do + @research_output = create(:research_output, plan: create(:plan)) + @presenter = Api::V2::ResearchOutputPresenter.new(output: @research_output) + end + + describe 'base :dataset attributes' do + before do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql(@research_output.output_type) + end + + it 'includes :title' do + expect(@json[:title]).to eql(@research_output.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@research_output.description) + end + + it 'includes :personal_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.personal_data) + expect(@json[:personal_data]).to eql(val) + end + + it 'includes :sensitive_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.sensitive_data) + expect(@json[:sensitive_data]).to eql(val) + end + + it 'includes :issued' do + expect(@json[:issued]).to eql(@research_output.release_date.to_formatted_s(:iso8601)) + end + + it 'includes :preservation_statement' do + expect(@json[:preservation_statement]).to eql(@presenter.preservation_statement) + end + + it 'includes :security_and_privacy' do + expect(@json[:security_and_privacy]).to eql(@presenter.security_and_privacy) + end + + it 'includes :data_quality_assurance' do + expect(@json[:data_quality_assurance]).to eql(@presenter.data_quality_assurance) + end + end + + describe ':distribution' do + before do + @repo = @research_output.repositories.first + @license = create(:license) + @research_output.license_id = @license.id + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :distributions' do + expect(@json[:distribution].any?).to be(true) + end + + it 'includes :title' do + expected = "Anticipated distribution for #{@research_output.title}" + expect(@json[:distribution].first[:title]).to eql(expected) + end + + it 'includes :byte_size' do + expect(@json[:distribution].first[:byte_size]).to eql(@research_output.byte_size) + end + + it 'includes :data_access' do + expect(@json[:distribution].first[:data_access]).to eql(@research_output.access) + end + + it 'includes host[:title]' do + expect(@json[:distribution].first[:host][:title]).to eql(@repo.name) + end + + it 'includes host[:description]' do + expect(@json[:distribution].first[:host][:description]).to eql(@repo.description) + end + + it 'includes host[:url]' do + expect(@json[:distribution].first[:host][:url]).to eql(@repo.homepage) + end + + it 'includes host[:dmproadmap_host_id]' do + result = @json[:distribution].first[:host][:dmproadmap_host_id][:identifier] + expect(result).to eql(@repo.uri) + end + + it 'includes license[:license_ref]' do + expect(@json[:distribution].first[:license].first[:license_ref]).to eql(@license.uri) + end + + it 'includes license[:start_date]' do + expected = @research_output.release_date.to_formatted_s(:iso8601) + expect(@json[:distribution].first[:license].first[:start_date]).to eql(expected) + end + end + + describe ':metadata' do + before do + @standard = create(:metadata_standard) + @research_output.metadata_standards << @standard + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :metadata' do + expect(@json[:metadata].any?).to be(true) + end + + it 'includes :description' do + uri = @standard.uri + metadata = @json[:metadata].select { |ms| ms[:metadata_standard_id][:identifier] == uri } + expected = "#{@standard.title} - #{@standard.description}" + expect(metadata.first[:description].start_with?(expected)).to be(true) + expect(metadata.first[:metadata_standard_id].present?).to be(true) + expect(metadata.first[:metadata_standard_id][:type]).to eql('url') + expect(metadata.first[:metadata_standard_id][:identifier]).to eql(uri) + end + end + + describe ':technical_resources' do + it 'is always an empty array because this has not been implemented' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:technical_resource].any?).to be(false) + end + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + research_domain = create(:research_domain) + @research_output.plan.research_domain_id = research_domain.id + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(research_domain.label)) + expect(@json[:keyword].include?("#{research_domain.identifier} - #{research_domain.label}")) + end + + it 'is not included if no ResearchDomain is defined' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].present?).to be(false) + end + end + end + end + + context ':output is a Plan' do + describe 'includes all of the dataset attributes' do + before do + @plan = create(:plan) + @research_domain = create(:research_domain) + @plan.research_domain_id = @research_domain.id + + render partial: 'api/v2/datasets/show', locals: { output: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql('dataset') + end + + it 'includes :title' do + expect(@json[:title]).to eql('Generic dataset') + end + + it 'includes :description' do + expect(@json[:description]).to eql('No individual datasets have been defined for this DMP.') + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(@research_domain.label)) + expect(@json[:keyword].include?("#{@research_domain.identifier} - #{@research_domain.label}")) + end + end + end + end +end diff --git a/spec/views/api/v2/error.json.jbuilder_spec.rb b/spec/views/api/v2/error.json.jbuilder_spec.rb new file mode 100644 index 0000000000..ca2337ff6d --- /dev/null +++ b/spec/views/api/v2/error.json.jbuilder_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/error.json.jbuilder' do + before do + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + @errors = [Faker::Lorem.sentence, Faker::Lorem.sentence] + + assign :payload, { message: @errors } + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + + render template: 'api/v2/error', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'error responses from controllers' do + it 'renders the standard_response partial' do + expect(response).to render_template(partial: 'api/v2/_standard_response') + end + + it ':errors contains an array of error messages' do + expect(@json[:message]).to eql(@errors) + end + end +end diff --git a/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb new file mode 100644 index 0000000000..1808117b19 --- /dev/null +++ b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/heartbeat.json.jbuilder' do + before do + render template: 'api/v2/heartbeat', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items]).to eql([]) + end +end diff --git a/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..602169fd75 --- /dev/null +++ b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/identifiers/_show.json.jbuilder' do + before do + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, value: Faker::Lorem.word, + identifier_scheme: @scheme) + render partial: 'api/v2/identifiers/show', locals: { identifier: @identifier } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the identifier attributes' do + it 'includes :type' do + expect(@json[:type]).to eql(@identifier.identifier_format) + end + + it 'includes :identifier' do + expect(@json[:identifier]).to eql(@identifier.value) + end + end +end diff --git a/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5480ae0ee1 --- /dev/null +++ b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/orgs/_show.json.jbuilder' do + before do + scheme = create(:identifier_scheme, name: 'ror') + @org = create(:org) + @ident = create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + render partial: 'api/v2/orgs/show', locals: { org: @org } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the org attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@org.name) + end + + it 'includes :abbreviation' do + expect(@json[:abbreviation]).to eql(@org.abbreviation) + end + + it 'includes :region' do + expect(@json[:region]).to eql(@org.region.abbreviation) + end + + it 'includes :affiliation_id' do + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + + it 'uses the ROR over the FundRef :affiliation_id' do + scheme = create(:identifier_scheme, name: 'fundref') + create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb new file mode 100644 index 0000000000..4395716c66 --- /dev/null +++ b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_cost.json.jbuilder' do + before do + # TODO: Implement this once the Currency question and Cost theme are in place + # and the PlanPresenter is extracting the info + @cost = { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + currency_code: Faker::Currency.code, + value: Faker::Number.decimal(l_digits: 2).to_s + }.with_indifferent_access + + render partial: 'api/v2/plans/cost', locals: { cost: @cost } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the cost attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@cost[:title]) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@cost[:description]) + end + + it 'includes :currency_code' do + expect(@json[:currency_code]).to eql(@cost[:currency_code]) + end + + it 'includes :value' do + expect(@json[:value]).to eql(@cost[:value]) + end + end +end diff --git a/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c9fe9ea015 --- /dev/null +++ b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_funding.json.jbuilder' do + before do + @funder = create(:org, :funder) + create(:identifier, identifiable: @funder, + identifier_scheme: create(:identifier_scheme, name: 'fundref')) + @funder.reload + @plan = create(:plan, funder: @funder, org: create(:org), identifier: SecureRandom.uuid) + create(:identifier, identifiable: @plan.org, + identifier_scheme: create(:identifier_scheme, name: 'ror')) + @grant = create(:identifier, identifiable: @plan) + @plan.update(grant_id: @grant.id, funding_status: 'funded') + @plan.reload + + render partial: 'api/v2/plans/funding', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the funding attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@funder.name) + end + + it 'includes :funding_status' do + expected = Api::V2::FundingPresenter.status(plan: @plan) + expect(@json[:funding_status]).to eql(expected) + end + + it 'includes :funder_ids' do + id = @funder.identifiers.first + expect(@json[:funder_id][:type]).to eql(id.identifier_format) + expect(@json[:funder_id][:identifier]).to eql(id.value) + end + + it 'includes :dmproadmap_funding_opportunity_identifier' do + identifier = @plan.identifier + expect(@json[:dmproadmap_funding_opportunity_id][:type]).to eql('other') + expect(@json[:dmproadmap_funding_opportunity_id][:identifier]).to eql(identifier) + end + + it 'includes :grant_ids' do + expect(@json[:grant_id][:type]).to eql(@grant.identifier_format) + expect(@json[:grant_id][:identifier]).to eql(@grant.value) + end + + it 'includes :dmproadmap_funded_affiliations' do + org = @plan.org + expect(@json[:dmproadmap_funded_affiliations].any?).to be(true) + affil = @json[:dmproadmap_funded_affiliations].last + expect(affil[:name]).to eql(org.name) + expect(affil[:affiliation_id][:type]).to eql(org.identifiers.last.identifier_format) + expect(affil[:affiliation_id][:identifier]).to eql(org.identifiers.last.value) + end + end +end diff --git a/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb new file mode 100644 index 0000000000..53af088379 --- /dev/null +++ b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_project.json.jbuilder' do + before do + @plan = build(:plan, funder: build(:org, :funder)) + render partial: 'api/v2/plans/project', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the project attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes :start' do + expect(@json[:start]).to eql(@plan.start_date.to_formatted_s(:iso8601)) + end + + it 'includes :end' do + expect(@json[:end]).to eql(@plan.end_date.to_formatted_s(:iso8601)) + end + + it 'includes the :funder' do + expect(@json[:funding].length).to be(1) + end + end +end diff --git a/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c706ca3bb6 --- /dev/null +++ b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_show.json.jbuilder' do + before do + Rails.configuration.x.madmp.enable_dmp_id_registration = true + + @plan = create(:plan) + @data_contact = create(:contributor, data_curation: true, plan: @plan) + @pi = create(:contributor, investigation: true, plan: @plan) + @plan.contributors = [@data_contact, @pi] + create(:identifier, identifiable: @plan) + + # Create an Api Client and connect it to the Plan + @client = create(:api_client) + scheme = create(:identifier_scheme, :for_plans, name: @client.name.downcase) + @client_identifier = create(:identifier, identifier_scheme: scheme, identifiable: @plan) + + @plan.save + @plan.reload + @presenter = Api::V2::PlanPresenter.new(plan: @plan) + end + + describe 'includes all of the DMP attributes' do + before do + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes the :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes the :language' do + # Set @plan.owner + @plan.roles << create(:role, :creator, user: create(:user)) + expected = Api::V2::LanguagePresenter.three_char_code( + lang: @plan.owner&.language&.abbreviation + ) + expect(@json[:language]).to eql(expected) + end + + it 'includes the :created' do + expect(@json[:created]).to eql(@plan.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@json[:modified]).to eql(@plan.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes :ethical_issues' do + expected = Api::V2::ConversionService.boolean_to_yes_no_unknown(@plan.ethical_issues) + expect(@json[:ethical_issues_exist]).to eql(expected) + end + + it 'includes :ethical_issues_description' do + expect(@json[:ethical_issues_description]).to eql(@plan.ethical_issues_description) + end + + it 'includes :ethical_issues_report' do + expect(@json[:ethical_issues_report]).to eql(@plan.ethical_issues_report) + end + + it 'returns the URL of the plan as the :dmp_id if no DMP ID is defined' do + expected = Rails.application.routes.url_helpers.api_v2_plan_url(@plan) + expect(@json[:dmp_id][:type]).to eql('url') + expect(@json[:dmp_id][:identifier]).to eql(expected) + end + + it 'includes the :contact' do + expect(@json[:contact][:mbox]).to eql(@data_contact.email) + end + + it 'includes the :contributors' do + emails = @json[:contributor].pluck(:mbox) + expect(emails.include?(@pi.email)).to be(true) + end + + # TODO: make sure this is working once the new Cost theme and Currency + # question type have been implemented + it 'includes the :cost' do + expect(@json[:cost]).to be_nil + end + + it 'includes the :project' do + expect(@json[:project].length).to be(1) + end + + it 'includes the :dataset' do + expect(@json[:dataset].length).to be(1) + end + end + + describe 'when the system mints DMP IDs', skip: 'DmpIdService not implemented' do + before do + scheme = create(:identifier_scheme) + DmpIdService.expects(:identifier_scheme).at_least(1).returns(scheme) + @doi = create(:identifier, value: '10.9999/123abc.zy/x23', identifiable: @plan, + identifier_scheme: scheme) + @plan.reload + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'returns the DMP ID for the :dmp_id if one is present' do + expect(@json[:dmp_id][:type]).to eql('doi') + expect(@json[:dmp_id][:identifier]).to eql(@doi.value) + end + end +end diff --git a/spec/views/api/v2/plans/index.json.jbuilder_spec.rb b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5d47770c52 --- /dev/null +++ b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/index.json.jbuilder' do + before do + @plan = create(:plan) + + @client = create(:api_client) + @items = [@plan] + @total_items = 1 + + render template: 'api/v2/plans/index' + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items].length).to be(1) + expect(@json[:items].first[:dmp][:title]).to eql(@plan.title) + end +end diff --git a/spec/views/api/v2/templates/index.json.jbuilder_spec.rb b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..99b35375ec --- /dev/null +++ b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/templates/index.json.jbuilder' do + before do + @application = Faker::Lorem.word + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + + @template1 = create(:template, :published, org: create(:org), phases: 1) + @template2 = create(:template, :published) + + assign :server, @application + assign :items, [@template1, @template2] + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'includes all of the Template attributes' do + before do + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + + @template = @json[:items].first[:dmp_template] + end + + it 'includes both templates' do + expect(@json[:items].length).to be(2) + end + + it 'includes the :title' do + expect(@template[:title]).to eql(@template1.title) + end + + it 'includes the :description' do + expect(@template[:description]).to eql(@template1.description) + end + + it 'includes the :version' do + expect(@template[:version]).to eql(@template1.version) + end + + it 'includes the :created' do + expect(@template[:created]).to eql(@template1.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@template[:modified]).to eql(@template1.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes the :affiliation' do + expect(@template[:affiliation][:name]).to eql(@template1.org.name) + end + + it 'includes the :template_ids' do + expect(@template[:template_id][:identifier]).to eql(@template1.id.to_s) + expect(@template[:template_id][:type]).to eql('other') + end + + # The show_url parameter is either false or not included + it 'includes the :phases' do + expect(@json[:items].first[:dmp_template][:phases]).to be_nil + end + end + + # The show_url parameter is true + describe 'when the show_phases url parameter is true' do + before do + @show_phases = true + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req, show_phases: @show_phases } + @json = JSON.parse(rendered).with_indifferent_access + end + end +end diff --git a/spec/views/devise/registrations/_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_api_token.html.erb_spec.rb new file mode 100644 index 0000000000..e543ba83f7 --- /dev/null +++ b/spec/views/devise/registrations/_api_token.html.erb_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'devise/registrations/_api_token.html.erb' do + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + context 'When a user has the `use_api` permission' do + it 'renders both the v2 and legacy API token sections' do + user = create(:user, :org_admin) + + render partial: 'devise/registrations/api_token', locals: { user: user } + + expect(rendered).to have_selector('#v2-api-token') + expect(rendered).to have_selector('#legacy-api-token') + end + end + + context 'When a user does not have the `use_api` permission' do + it 'renders only the v2 API token section' do + user = create(:user) + + render partial: 'devise/registrations/api_token', locals: { user: user } + + expect(rendered).to have_selector('#v2-api-token') + expect(rendered).not_to have_selector('#legacy-api-token') + end + end +end diff --git a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb new file mode 100644 index 0000000000..712431bd60 --- /dev/null +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'devise/registrations/_v2_api_token.html.erb' do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + + def render_token_partial(token: nil) + render partial: 'devise/registrations/v2_api_token', locals: { user: user, token: token } + end + + context 'when the OAuth application exists' do + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + it 'displays the regenerate button when no token is present' do + render_token_partial(token: nil) + expect(rendered).to have_selector('button', text: 'Regenerate token') + end + + context 'when user has a token' do + let(:plaintext_token) { 'plaintext-token-value' } + + it 'displays the token and disables the regenerate button' do + render_token_partial(token: plaintext_token) + expect(rendered).to have_selector('#api-token-val') + expect(rendered).not_to have_content('Click the button below to generate an API token') + expect(rendered).to have_selector('button[disabled]', text: 'Regenerate token') + end + end + + context 'when user does not have a token' do + it 'displays the generate message' do + render_token_partial(token: nil) + expect(rendered).to have_content('Click the button below to generate an API token') + expect(rendered).not_to have_selector('#api-token-val') + end + end + end + + context 'when the OAuth application does not exist' do + it 'displays the warning message and helpdesk email link' do + render_token_partial(token: nil) + expect(rendered).to have_selector('.alert-warning') + expect(rendered).to have_content('V2 API token service is currently unavailable') + expect(rendered).to have_link(href: "mailto:#{Rails.application.config.x.organisation.helpdesk_email}") + end + + it 'does not display the token or regenerate button' do + render_token_partial(token: nil) + expect(rendered).not_to have_selector('button', text: 'Regenerate token') + expect(rendered).not_to have_selector('code') + end + end +end diff --git a/yarn.lock b/yarn.lock index 257c68d2eb..e899f3b48f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1244,6 +1244,11 @@ resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.1.7.tgz#b09dc5b2105dd267e8374c47e4490240451dc7f6" integrity sha512-0e7WQ4LE/+LEfW2zfAw9ppsB6A8RmxbdAUPAF++UT80epY+7emuQDkKXmaK0a9lp6An50RvzezI0cIQjp1A58w== +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== + "@socket.io/component-emitter@~3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" @@ -1570,47 +1575,96 @@ array-buffer-byte-length@^1.0.0: call-bind "^1.0.2" is-array-buffer "^3.0.1" -array-includes@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.6.tgz#9e9e720e194f198266ba9e18c29e6a9b0e4b225f" - integrity sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw== +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== + dependencies: + call-bound "^1.0.3" + is-array-buffer "^3.0.5" + +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - get-intrinsic "^1.1.3" - is-string "^1.0.7" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" -array.prototype.flat@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz#ffc6576a7ca3efc2f46a143b9d1dda9b4b3cf5e2" - integrity sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA== +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" -array.prototype.flatmap@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz#1aae7903c2100433cb8261cd4ed310aab5c4a183" - integrity sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ== +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" - es-shim-unscopables "^1.0.0" + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" arrify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +available-typed-arrays@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" + integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== + dependencies: + possible-typed-array-names "^1.0.0" + babel-loader@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c" @@ -1751,6 +1805,14 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" @@ -1770,6 +1832,24 @@ call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1979,6 +2059,33 @@ custom-event@~1.0.0: resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + date-format@^4.0.14: version "4.0.14" resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400" @@ -1998,21 +2105,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -debug@^4.3.1: - version "4.3.5" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" - integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== - dependencies: - ms "2.1.2" - -debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.6" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== @@ -2024,7 +2117,7 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -define-data-property@^1.1.4: +define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== @@ -2041,6 +2134,15 @@ define-properties@^1.1.3, define-properties@^1.1.4: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +define-properties@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" + integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== + dependencies: + define-data-property "^1.0.1" + has-property-descriptors "^1.0.0" + object-keys "^1.1.1" + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -2080,6 +2182,15 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2193,6 +2304,66 @@ es-abstract@^1.19.0, es-abstract@^1.20.4: unbox-primitive "^1.0.2" which-typed-array "^1.1.9" +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.1.tgz#f0c131ed5ea1bb2411134a8dd94def09c46c7899" + integrity sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + es-define-property@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" @@ -2200,6 +2371,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -2210,6 +2386,13 @@ es-module-lexer@^2.0.0: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8" @@ -2219,12 +2402,22 @@ es-set-tostringtag@^2.0.1: has "^1.0.3" has-tostringtag "^1.0.0" -es-shim-unscopables@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" - integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== dependencies: - has "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" es-to-primitive@^1.2.1: version "1.2.1" @@ -2235,6 +2428,15 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + escalade@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" @@ -2270,42 +2472,46 @@ eslint-config-airbnb-base@^15.0.0: object.entries "^1.1.5" semver "^6.3.0" -eslint-import-resolver-node@^0.3.7: - version "0.3.7" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz#83b375187d412324a1963d84fa664377a23eb4d7" - integrity sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA== +eslint-import-resolver-node@^0.3.9: + version "0.3.9" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" + integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== dependencies: debug "^3.2.7" - is-core-module "^2.11.0" - resolve "^1.22.1" + is-core-module "^2.13.0" + resolve "^1.22.4" -eslint-module-utils@^2.7.4: - version "2.7.4" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz#4f3e41116aaf13a20792261e61d3a2e7e0583974" - integrity sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA== +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" -eslint-plugin-import@^2.26.0: - version "2.27.5" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz#876a6d03f52608a3e5bb439c2550588e51dd6c65" - integrity sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow== +eslint-plugin-import@^2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: - array-includes "^3.1.6" - array.prototype.flat "^1.3.1" - array.prototype.flatmap "^1.3.1" + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" - eslint-import-resolver-node "^0.3.7" - eslint-module-utils "^2.7.4" - has "^1.0.3" - is-core-module "^2.11.0" + eslint-import-resolver-node "^0.3.9" + eslint-module-utils "^2.12.1" + hasown "^2.0.2" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" - object.values "^1.1.6" - resolve "^1.22.1" - semver "^6.3.0" - tsconfig-paths "^3.14.1" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.1" + semver "^6.3.1" + string.prototype.trimend "^1.0.9" + tsconfig-paths "^3.15.0" eslint-scope@5.1.1: version "5.1.1" @@ -2553,6 +2759,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + fs-extra@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -2587,11 +2800,28 @@ function.prototype.name@^1.1.5: es-abstract "^1.19.0" functions-have-names "^1.2.2" -functions-have-names@^1.2.2: +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + +functions-have-names@^1.2.2, functions-have-names@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -2622,6 +2852,30 @@ get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: has "^1.0.3" has-symbols "^1.0.3" +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -2630,6 +2884,15 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" @@ -2680,6 +2943,14 @@ globalthis@^1.0.3: dependencies: define-properties "^1.1.3" +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -2687,6 +2958,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -2731,11 +3007,23 @@ has-proto@^1.0.1: resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" @@ -2743,12 +3031,19 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== -hasown@^2.0.0: +hasown@^2.0.0, hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -2835,6 +3130,15 @@ internal-slot@^1.0.5: has "^1.0.3" side-channel "^1.0.4" +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + interpret@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" @@ -2849,11 +3153,31 @@ is-array-buffer@^3.0.1, is-array-buffer@^3.0.2: get-intrinsic "^1.2.0" is-typed-array "^1.1.10" +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -2861,6 +3185,13 @@ is-bigint@^1.0.1: dependencies: has-bigints "^1.0.1" +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -2876,17 +3207,34 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.11.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4" - integrity sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ== +is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== dependencies: - has "^1.0.3" + hasown "^2.0.2" + +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" is-date-object@^1.0.1: version "1.0.5" @@ -2895,16 +3243,42 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.10: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -2912,11 +3286,21 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== +is-negative-zero@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" + integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== + is-number-object@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc" @@ -2924,6 +3308,14 @@ is-number-object@^1.0.4: dependencies: has-tostringtag "^1.0.0" +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -2949,6 +3341,21 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" @@ -2956,6 +3363,13 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -2963,6 +3377,14 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" @@ -2970,6 +3392,15 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typed-array@^1.1.10, is-typed-array@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.10.tgz#36a5b5cb4189b575d1a3e4b08536bfb485801e3f" @@ -2981,6 +3412,18 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.9: gopd "^1.0.1" has-tostringtag "^1.0.0" +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -2988,6 +3431,26 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isbinaryfile@^4.0.8: version "4.0.10" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" @@ -3282,6 +3745,11 @@ make-dir@^3.0.2: dependencies: semver "^6.0.0" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -3396,6 +3864,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -3411,6 +3884,18 @@ object.assign@^4.1.2, object.assign@^4.1.4: has-symbols "^1.0.3" object-keys "^1.1.1" +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + object.entries@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.6.tgz#9737d0e5b8291edd340a3e3264bb8a3b00d5fa23" @@ -3420,14 +3905,34 @@ object.entries@^1.1.5: define-properties "^1.1.4" es-abstract "^1.20.4" -object.values@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.6.tgz#4abbaa71eba47d63589d402856f908243eea9b1d" - integrity sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw== +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" + +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" on-finished@2.4.1: version "2.4.1" @@ -3462,6 +3967,15 @@ optionator@^0.9.1: type-check "^0.4.0" word-wrap "^1.2.3" +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -3569,6 +4083,11 @@ popper.js@^1.16.1: resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== +possible-typed-array-names@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3645,6 +4164,20 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regenerate-unicode-properties@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c" @@ -3678,6 +4211,18 @@ regexp.prototype.flags@^1.4.3: define-properties "^1.1.3" functions-have-names "^1.2.2" +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + regexpu-core@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b" @@ -3729,7 +4274,7 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1: +resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0: version "1.22.2" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.2.tgz#0ed0943d4e301867955766c9f3e1ae6d01c6845f" integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g== @@ -3738,6 +4283,15 @@ resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.22.4: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -3762,11 +4316,30 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + safe-buffer@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + safe-regex-test@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" @@ -3776,6 +4349,15 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -3809,7 +4391,7 @@ schema-utils@^4.0.0, schema-utils@^4.3.0, schema-utils@^4.3.3: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -3821,7 +4403,7 @@ serialize-javascript@^6.0.2: dependencies: randombytes "^2.1.0" -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -3833,6 +4415,25 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" +set-function-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" + integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + functions-have-names "^1.2.3" + has-property-descriptors "^1.0.2" + +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -3857,6 +4458,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -3867,6 +4497,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + socket.io-adapter@~2.5.2: version "2.5.5" resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" @@ -3929,6 +4570,14 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + streamroller@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff" @@ -3947,6 +4596,19 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + string.prototype.trim@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz#a68352740859f6893f14ce3ef1bb3037f7a90533" @@ -3956,14 +4618,15 @@ string.prototype.trim@^1.2.7: define-properties "^1.1.4" es-abstract "^1.20.4" -string.prototype.trimend@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz#c4a27fa026d979d79c04f17397f250a462944533" - integrity sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ== +string.prototype.trimend@^1.0.6, string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.1.4" - es-abstract "^1.20.4" + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" string.prototype.trimstart@^1.0.6: version "1.0.6" @@ -3974,6 +4637,15 @@ string.prototype.trimstart@^1.0.6: define-properties "^1.1.4" es-abstract "^1.20.4" +string.prototype.trimstart@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" + integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -4080,10 +4752,10 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -tsconfig-paths@^3.14.1: - version "3.14.2" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088" - integrity sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g== +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== dependencies: "@types/json5" "^0.0.29" json5 "^1.0.2" @@ -4110,6 +4782,39 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + typed-array-length@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb" @@ -4119,6 +4824,18 @@ typed-array-length@^1.0.4: for-each "^0.3.3" is-typed-array "^1.1.9" +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + ua-parser-js@^0.7.30: version "0.7.38" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.38.tgz#f497d8a4dc1fec6e854e5caa4b2f9913422ef054" @@ -4134,6 +4851,16 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + undici-types@~6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.13.0.tgz#e3e79220ab8c81ed1496b5812471afd7cf075ea5" @@ -4291,6 +5018,59 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.20" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which-typed-array@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"