diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c59aeef03e5..8106685b3e7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,33 +1,8 @@ # syntax = docker/dockerfile:1.4 -ARG RUBY_VERSION=3.3 +ARG RUBY_VERSION=3.3.5 +FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION -FROM ruby:${RUBY_VERSION}-alpine - -ENV USERNAME=vscode \ - UID=1000 \ - GID=1000 - -# Install the git-credential-manager package via the dotnet tooling to -RUN apk update && apk add --no-cache \ - github-cli \ - git \ - build-base \ - bash \ - mandoc \ - man-pages \ - tzdata \ - libpq-dev \ - libmagic \ - nodejs \ - sudo - -# create non-root group and user -RUN addgroup -g $GID $USERNAME \ - && adduser -s /bin/bash -u $UID -G $USERNAME $USERNAME --disabled-password --gecos "" - -# set sudo permissions for vscode user -RUN echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME -RUN chmod 0440 /etc/sudoers.d/$USERNAME - -USER $USERNAME +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends pkg-config \ + && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5053ae30358..d05adf8a1e8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,12 +5,13 @@ "docker-compose.yml", "../docker-compose.yml" ], - "service": "app", + "service": "rails-app", "runServices": [ "db", "cache", "search", - "toxiproxy" + "toxiproxy", + "selenium" ], "forwardPorts": [ 3000, // Rails @@ -23,10 +24,20 @@ "onCreateCommand": "bin/setup", // Use 'updateContentCommand' to run commands when the container is updated. "updateContentCommand": "bin/setup", + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + // "ghcr.io/rails/devcontainer/features/activestorage": {}, + "ghcr.io/rails/devcontainer/features/postgres-client": {} + }, // Configure tool-specific properties. "containerEnv": { "EDITOR": "code --wait", - "GIT_EDITOR": "code --wait" + "GIT_EDITOR": "code --wait", + "CAPYBARA_SERVER_PORT": "45678", + "SELENIUM_HOST": "selenium", + "ELASTICSEARCH_URL": "http://search:9200", + "DATABASE_URL": "postgres://postgres@db:5432" }, "customizations": { "codespaces": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 26bfabc6840..9607a18c49f 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,12 +1,16 @@ -version: "3" services: - app: + rails-app: build: context: . dockerfile: Dockerfile - command: /bin/sh -c "while sleep 1000; do :; done" + command: sleep infinity volumes: - ../..:/workspaces:cached - environment: - - ELASTICSEARCH_URL=http://search:9200 - - DATABASE_URL=postgres://postgres@db:5432 + depends_on: + - search + - db + - selenium + + selenium: + image: selenium/standalone-chromium + restart: unless-stopped diff --git a/.github/actions/setup-rubygems.org/action.yml b/.github/actions/setup-rubygems.org/action.yml index c1af62e1fea..f72039cf3e6 100644 --- a/.github/actions/setup-rubygems.org/action.yml +++ b/.github/actions/setup-rubygems.org/action.yml @@ -7,6 +7,10 @@ inputs: rubygems-version: description: "RubyGems version to use" required: true + install-avo-pro: + description: "Install Avo gem" + required: false + default: "true" runs: using: "composite" steps: @@ -14,7 +18,12 @@ runs: shell: bash run: | docker compose up -d --wait - - uses: ruby/setup-ruby@3783f195e29b74ae398d7caca108814bbafde90e # v1.180.1 + - name: Configure bundler environment + shell: bash + if: github.secret_source != 'None' && inputs.install-avo-pro == 'true' + run: | + printf "BUNDLE_WITH=avo\nRAILS_GROUPS=avo\n" >> $GITHUB_ENV + - uses: ruby/setup-ruby@52753b7da854d5c07df37391a986c76ab4615999 # v1.191.0 with: ruby-version: ${{ inputs.ruby-version }} bundler-cache: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 361185db215..7b2938fd879 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,7 +26,7 @@ permissions: # added using https://github.com/step-security/secure-workflows jobs: analyze: name: Analyze - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: actions: read contents: read @@ -41,11 +41,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/init@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/autobuild@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -71,6 +71,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/analyze@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7ddff773819..1cce01e300f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,24 +12,24 @@ permissions: jobs: build: name: Docker build (and optional push) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 env: - RUBYGEMS_VERSION: 3.5.14 - RUBY_VERSION: 3.3.3 + RUBYGEMS_VERSION: "3.5.20" + RUBY_VERSION: "3.3.5" steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@4fd812986e6c8c2a69e18311145f9371337f27d4 # master + uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # master - name: Cache Docker layers - uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-rubygems-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-rubygems-org - name: Install and start services (needed for image test) - run: docker-compose up -d + run: docker compose up -d - name: Configure AWS credentials from Production account uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 if: github.secret_source != 'None' @@ -41,6 +41,8 @@ jobs: uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 - name: build, test and optionally push docker image run: ./script/build_docker.sh + env: + BUNDLE_PACKAGER__DEV: ${{ secrets.BUNDLE_PACKAGER__DEV }} # Temp fix # https://github.com/docker/build-push-action/issues/252 # https://github.com/moby/buildkit/issues/1896 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e27fe434e21..8b9819d6de8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,37 +10,37 @@ permissions: jobs: rubocop: name: Rubocop - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: ruby/setup-ruby@97e35c5302afcf3f5ac1df3fca9343d32536b286 # v1.184.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0 with: bundler-cache: true - name: Rubocop run: bundle exec rubocop brakeman: name: Brakeman - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: ruby/setup-ruby@97e35c5302afcf3f5ac1df3fca9343d32536b286 # v1.184.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0 with: bundler-cache: true - name: Brakeman run: bundle exec brakeman importmap: name: Importmap Verify - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: ruby/setup-ruby@97e35c5302afcf3f5ac1df3fca9343d32536b286 # v1.184.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0 with: bundler-cache: true - name: Importmap Verify run: bundle exec rake importmap:verify kubeconform: name: Kubeconform - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: kubernetes_version: ["1.29.1"] @@ -50,8 +50,8 @@ jobs: steps: - name: login to Github Packages run: echo "${{ github.token }}" | docker login https://ghcr.io -u ${GITHUB_ACTOR} --password-stdin - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: ruby/setup-ruby@97e35c5302afcf3f5ac1df3fca9343d32536b286 # v1.184.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ruby/setup-ruby@a2bbe5b1b236842c1cb7dd11e8e3b51e0a616acc # v1.202.0 with: bundler-cache: true - name: krane render @@ -60,12 +60,27 @@ jobs: env: ENVIRONMENT: "${{ matrix.environment }}" REVISION: "${{ github.sha }}" - - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: "${{ matrix.environment }}.rendered.yaml" path: "config/deploy/${{ matrix.environment }}.rendered.yaml" - name: kubeconform - uses: docker://ghcr.io/yannh/kubeconform:v0.6.3 + uses: docker://ghcr.io/yannh/kubeconform@sha256:03f6b236ef64f20b4bc950209d6254b109e23b4b05e7811649f59eae5659fa58 # v0.6.3 with: entrypoint: "/kubeconform" args: "-strict -summary -output json --kubernetes-version ${{ matrix.kubernetes_version }} config/deploy/${{ matrix.environment }}.rendered.yaml" + frizbee: + name: Frizbee + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: segiddins/frizbee-action@c162fdaa6c73525a577d2d6eb193683dfc9ba2be # segiddins/run-in-place + env: + GITHUB_TOKEN: ${{ github.token }} + with: + action_paths: '[".github/workflows", ".github/actions"]' + dockerfiles: '["./Dockerfile", ".devcontainer/Dockerfile"]' + docker_compose: '["./docker-compose.yml", ".devcontainer/docker-compose.yml"]' + fail_on_unpinned: true + open_pr: false + repo_root: "." diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 6c7bd34107c..77d45a51c4d 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -20,7 +20,7 @@ permissions: read-all jobs: analysis: name: Scorecards analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: # Needed to upload the results to code-scanning dashboard. security-events: write @@ -32,12 +32,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v3.1.0 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v3.1.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@dc50aa9510b46c811795eb24b2f1ba02a914e534 # v2.3.3 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11 + uses: github/codeql-action/upload-sarif@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4 with: sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bcdfd2a0249..29f6aae442a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: status_check: name: All required tests passing check needs: [rails] - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 if: always() steps: - run: /bin/${{ (needs.rails.result == 'success' || needs.rails.result == 'skipped') }} @@ -26,29 +26,37 @@ jobs: matrix: rubygems: - name: locked - version: "3.5.14" + version: "3.5.20" - name: latest version: latest - ruby_version: ["3.3.3"] + ruby_version: ["3.3.5"] tests: - name: general command: test - name: system command: test:system + include: + - rubygems: { name: latest, version: latest } + ruby_version: "3.3.5" + tests: { name: "avo without pro", command: "test test/*/avo" } name: Rails tests ${{ matrix.tests.name }} (RubyGems ${{ matrix.rubygems.name }}, Ruby ${{ matrix.ruby_version }}) runs-on: ubuntu-22.04 env: RUBYGEMS_VERSION: ${{ matrix.rubygems.version }} # Fail hard when Toxiproxy is not running to ensure all tests (even Toxiproxy optional ones) are passing REQUIRE_TOXIPROXY: true + REQUIRE_AVO_PRO: ${{ github.secret_source != 'None' && matrix.tests.name != 'avo without pro' }} + AVO_LICENSE_KEY: ${{ secrets.AVO_LICENSE_KEY }} + BUNDLE_PACKAGER__DEV: ${{ secrets.BUNDLE_PACKAGER__DEV }} steps: - - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup rubygems.org uses: ./.github/actions/setup-rubygems.org with: ruby-version: ${{ matrix.ruby_version }} rubygems-version: ${{ matrix.rubygems.version }} + install-avo-pro: ${{ matrix.tests.name != 'avo without pro' }} - name: Tests ${{ matrix.tests.name }} id: test-all @@ -56,7 +64,7 @@ jobs: - name: Save capybara screenshots if: ${{ failure() && steps.test-all.outcome == 'failure' }} - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: capybara-screenshots-${{ matrix.tests.name }}-${{ matrix.rubygems.name }} path: tmp/capybara @@ -64,6 +72,6 @@ jobs: - name: Upload coverage to Codecov if: matrix.rubygems.name == 'locked' && (success() || failure()) - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + uses: codecov/codecov-action@3b1354a6c45db9f1008891f4eafc1a7e94ce1d18 # v5.0.1 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 1f91c4ccd45..80cf647e05c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ latest_dump coverage REVISION /.env* +/.pumaenv.local /doc/erd.* - /app/assets/builds/* !/app/assets/builds/.keep diff --git a/.pumaenv b/.pumaenv new file mode 100644 index 00000000000..57afa2d530a --- /dev/null +++ b/.pumaenv @@ -0,0 +1 @@ +[[ -e ".pumaenv.local" ]] && source .pumaenv.local diff --git a/.rubocop.yml b/.rubocop.yml index b9f77f71b2e..f7a301544aa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -148,9 +148,6 @@ Style/StringLiterals: Style/FrozenStringLiteralComment: Enabled: false -Security/MarshalLoad: - Enabled: false - Style/EmptyMethod: EnforcedStyle: expanded diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f9e7b1044fb..995a27e1011 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,13 +1,13 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2022-06-17 19:11:11 UTC using RuboCop version 1.26.1. +# on 2024-07-03 00:52:11 UTC using RuboCop version 1.60.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 15 -# This cop supports safe auto-correction (--auto-correct). +# Offense count: 30 +# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. # Include: **/*.gemfile, **/Gemfile, **/gems.rb Bundler/OrderedGems: @@ -21,26 +21,25 @@ Lint/DuplicateMethods: - 'test/functional/api/v1/rubygems_controller_test.rb' # Offense count: 1 -# This cop supports unsafe auto-correction (--auto-correct-all). -# Configuration parameters: AllowComments. +# This cop supports unsafe autocorrection (--autocorrect-all). Lint/UselessMethodDefinition: Exclude: - 'config/initializers/gem_version_monkeypatch.rb' -# Offense count: 5 -# Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. -# IgnoredMethods: refine +# Offense count: 16 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +# AllowedMethods: refine Metrics/BlockLength: - Max: 64 + Max: 61 # Offense count: 1 -# Configuration parameters: IgnoredMethods. +# Configuration parameters: AllowedMethods, AllowedPatterns, Max. Metrics/CyclomaticComplexity: - Max: 10 Exclude: - 'app/models/concerns/rubygem_searchable.rb' # Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: EnforcedStyleForLeadingUnderscores. # SupportedStylesForLeadingUnderscores: disallowed, required, optional Naming/MemoizedInstanceVariableName: @@ -48,44 +47,39 @@ Naming/MemoizedInstanceVariableName: - 'lib/rubygem_fs.rb' # Offense count: 16 -# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers. +# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. # SupportedStyles: snake_case, normalcase, non_integer -# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339 +# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 Naming/VariableNumber: Exclude: - 'test/functional/api/v1/downloads_controller_test.rb' - - 'test/models/gem_download_test.rb' - 'test/models/concerns/rubygem_searchable_test.rb' + - 'test/models/gem_download_test.rb' # Offense count: 1 -Rails/DeprecatedActiveModelErrorsMethods: +# Configuration parameters: Database, Include. +# SupportedDatabases: mysql, postgresql +# Include: db/**/*.rb +Rails/BulkChangeTable: Exclude: - - 'app/models/rubygem.rb' + - 'db/migrate/20240522185716_create_good_job_process_lock_ids.rb' -# Offense count: 78 -# This cop supports unsafe auto-correction (--auto-correct-all). +# Offense count: 83 +# This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: Whitelist, AllowedMethods, AllowedReceivers. -# Whitelist: find_by_sql -# AllowedMethods: find_by_sql -# AllowedReceivers: Gem::Specification +# Whitelist: find_by_sql, find_by_token_for +# AllowedMethods: find_by_sql, find_by_token_for +# AllowedReceivers: Gem::Specification, page Rails/DynamicFindBy: Enabled: false -# Offense count: 2 +# Offense count: 6 # Configuration parameters: Include. # Include: app/models/**/*.rb Rails/HasManyOrHasOneDependent: Exclude: - 'app/models/rubygem.rb' -# Offense count: 7 -# This cop supports safe auto-correction (--auto-correct). -# Configuration parameters: Include. -# Include: spec/**/*, test/**/* -Rails/HttpPositionalArguments: - Exclude: - - 'test/unit/gemcutter/middleware/redirector_test.rb' - # Offense count: 1 # Configuration parameters: Include. # Include: spec/**/*.rb, test/**/*.rb @@ -93,7 +87,7 @@ Rails/I18nLocaleAssignment: Exclude: - 'test/test_helper.rb' -# Offense count: 6 +# Offense count: 7 Rails/I18nLocaleTexts: Exclude: - 'app/mailers/mailer.rb' @@ -107,11 +101,10 @@ Rails/OutputSafety: - 'app/helpers/application_helper.rb' - 'app/helpers/rubygems_helper.rb' -# Offense count: 7 -# This cop supports safe auto-correction (--auto-correct). +# Offense count: 6 +# This cop supports unsafe autocorrection (--autocorrect-all). Rails/RedundantPresenceValidationOnBelongsTo: Exclude: - - 'app/models/api_key.rb' - 'app/models/api_key_rubygem_scope.rb' - 'app/models/deletion.rb' - 'app/models/ownership_call.rb' @@ -120,13 +113,13 @@ Rails/RedundantPresenceValidationOnBelongsTo: - 'app/models/version.rb' # Offense count: 1 -# This cop supports safe auto-correction (--auto-correct). +# This cop supports unsafe autocorrection (--autocorrect-all). Rails/RelativeDateConstant: Exclude: - 'app/models/gem_typo.rb' -# Offense count: 43 -# This cop supports safe auto-correction (--auto-correct). +# Offense count: 39 +# This cop supports unsafe autocorrection (--autocorrect-all). Security/JSONLoad: Exclude: - 'config/initializers/yaml_renderer.rb' @@ -144,7 +137,7 @@ Security/JSONLoad: - 'test/models/web_hook_test.rb' # Offense count: 2 -# This cop supports safe auto-correction (--auto-correct). +# This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedOctalStyle. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: @@ -152,8 +145,8 @@ Style/NumericLiteralPrefix: - 'test/models/pusher_test.rb' # Offense count: 3 -# This cop supports unsafe auto-correction (--auto-correct-all). -# Configuration parameters: EnforcedStyle, IgnoredMethods. +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. # SupportedStyles: predicate, comparison Style/NumericPredicate: Exclude: diff --git a/.ruby-version b/.ruby-version index 619b5376684..fa7adc7ac72 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.3 +3.3.5 diff --git a/Dockerfile b/Dockerfile index 64d7cf992f9..5f90f78e023 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# syntax = docker/dockerfile:1.4 +# syntax = docker/dockerfile:1.10 # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile -ARG RUBY_VERSION=3.3.3 +ARG RUBY_VERSION=3.3.5 ARG ALPINE_VERSION=3.20 FROM ruby:$RUBY_VERSION-alpine${ALPINE_VERSION} as base @@ -40,18 +40,23 @@ RUN \ build-base \ linux-headers \ zlib-dev \ - tzdata + tzdata \ + git WORKDIR /app ENV RAILS_ENV="production" +ARG BUNDLE_WITH="" # Install application gems COPY Gemfile* .ruby-version /app/ -RUN --mount=type=cache,id=bld-gem-cache,sharing=locked,target=/srv/vendor < 7.1.0", ">= 7.1.3.2" +gem "rails", "~> 7.2.1" gem "rails-i18n", "~> 7.0" -gem "aws-sdk-s3", "~> 1.156" -gem "aws-sdk-sqs", "~> 1.80" +gem "aws-sdk-s3", "~> 1.171" +gem "aws-sdk-sqs", "~> 1.88" gem "bootsnap", "~> 1.18" -gem "clearance", "~> 2.7" +gem "clearance", "~> 2.9" gem "dalli", "~> 3.2" -gem "datadog", "~> 2.1", require: "datadog/auto_instrument" -gem "dogstatsd-ruby", "~> 5.5" -gem "google-protobuf", "~> 4.27" -gem "faraday", "~> 2.9" +gem "datadog", "~> 2.7" +gem "dogstatsd-ruby", "~> 5.6" +gem "google-protobuf", "~> 4.28" +gem "faraday", "~> 2.12" gem "faraday-retry", "~> 2.2" -gem "good_job", "~> 3.29" +gem "faraday-restrict-ip-addresses", "~> 0.3.0", require: "faraday/restrict_ip_addresses" +gem "good_job", "~> 3.99" gem "gravtastic", "~> 3.2" -gem "honeybadger", "~> 5.5.1" # see https://github.com/rubygems/rubygems.org/pull/4598 +gem "honeybadger", "~> 5.5.1", require: false # see https://github.com/rubygems/rubygems.org/pull/4598 gem "http_accept_language", "~> 2.1" gem "kaminari", "~> 1.2" -gem "launchdarkly-server-sdk", "~> 8.6" +gem "launchdarkly-server-sdk", "~> 8.8" gem "mail", "~> 2.8" -gem "octokit", "~> 9.1" +gem "octokit", "~> 9.2" gem "omniauth-github", "~> 2.0" gem "omniauth", "~> 2.1" gem "omniauth-rails_csrf_protection", "~> 1.0" @@ -30,17 +31,17 @@ gem "openid_connect", "~> 2.3" gem "pg", "~> 1.5" gem "puma", "~> 6.4" gem "rack", "~> 3.1" -gem "rackup", "~> 2.1" -gem "rack-utf8_sanitizer", "~> 1.8" +gem "rackup", "~> 2.2" +gem "rack-sanitizer", "~> 2.0" gem "rbtrace", "~> 0.5.1" gem "rdoc", "~> 6.7" -gem "roadie-rails", "~> 3.2" +gem "roadie-rails", "~> 3.3" gem "ruby-magic", "~> 0.6" gem "shoryuken", "~> 6.2", require: false -gem "statsd-instrument", "~> 3.8" +gem "statsd-instrument", "~> 3.9" gem "validates_formatting_of", "~> 0.9" -gem "opensearch-ruby", "~> 3.3" -gem "searchkick", "~> 5.3" +gem "opensearch-ruby", "~> 3.4" +gem "searchkick", "~> 5.4" gem "faraday_middleware-aws-sigv4", "~> 1.0" gem "xml-simple", "~> 1.1" gem "compact_index", "~> 0.15.0" @@ -49,46 +50,50 @@ gem "rqrcode", "~> 2.1" gem "rotp", "~> 6.2" gem "unpwn", "~> 1.0" gem "webauthn", "~> 3.1" -gem "browser", "~> 6.0" +gem "browser", "~> 6.1" gem "bcrypt", "~> 3.1" -gem "maintenance_tasks", "~> 2.7" -gem "strong_migrations", "~> 2.0" +gem "maintenance_tasks", "~> 2.8" +gem "strong_migrations", "~> 2.1" gem "phlex-rails", "~> 1.2" -gem "discard", "~> 1.3" +gem "discard", "~> 1.4" gem "user_agent_parser", "~> 2.18" -gem "pghero", "~> 3.5" +gem "pghero", "~> 3.6" gem "timescaledb", "~> 0.3.0" +gem "faraday-multipart", "~> 1.0" # Admin dashboard -gem "avo", "~> 2.51" -gem "view_component", "~> 3.12" -gem "pundit", "~> 2.3" -gem "chartkick", "~> 5.0" -gem "groupdate", "~> 6.2" +gem "avo", "~> 3.13" +gem "pagy", "~> 8.4" +gem "view_component", "~> 3.14.0" +gem "pundit", "~> 2.4" +gem "chartkick", "~> 5.1" +gem "groupdate", "~> 6.5" +gem "prop_initializer", "~> 0.2" + +group :avo, optional: true do + source "https://packager.dev/avo-hq/" do + gem "avo-advanced", "~> 3.14" + end +end # Logging gem "amazing_print", "~> 1.6" -gem "rails_semantic_logger", "~> 4.16" -gem "pp", "0.5.0" +gem "rails_semantic_logger", "~> 4.17" +gem "pp", "0.6.1" # Former default gems gem "csv", "~> 3.3" # zeitwerk-2.6.12 gem "observer", "~> 0.1.2" # launchdarkly-server-sdk-8.0.0 # Assets -gem "sprockets-rails", "~> 3.5" +gem "propshaft", "~> 1.1.0" gem "importmap-rails", "~> 2.0" gem "stimulus-rails", "~> 1.3" # this adds stimulus-loading.js so it must be available at runtime +gem "local_time", "~> 3.0" gem "better_html", "~> 2.1" group :assets, :development do - gem "tailwindcss-rails", "~> 2.6" -end - -group :assets do - gem "dartsass-sprockets", "~> 3.1" - gem "terser", "~> 1.2" - gem "autoprefixer-rails", "~> 10.4" + gem "tailwindcss-rails", "~> 3.0" end group :development, :test do @@ -98,7 +103,7 @@ group :development, :test do gem "dotenv-rails", "~> 3.1" gem "lookbook", "~> 2.3" - gem "brakeman", "~> 6.1", require: false + gem "brakeman", "~> 6.2", require: false # used to find n+1 queries gem "prosopite", "~> 1.4" @@ -118,26 +123,29 @@ group :development do gem "listen", "~> 3.9" gem "letter_opener", "~> 1.10" gem "letter_opener_web", "~> 3.0" - gem "derailed_benchmarks", "~> 2.1" - gem "memory_profiler", "~> 1.0" + gem "derailed_benchmarks", "~> 2.2" + gem "memory_profiler", "~> 1.1" end group :test do - gem "datadog-ci", "~> 1.1" - gem "minitest", "~> 5.24", require: false - gem "minitest-retry", "~> 0.2.2" + gem "datadog-ci", "~> 1.8" + gem "minitest", "~> 5.25", require: false + gem "minitest-retry", "~> 0.2.3" gem "capybara", "~> 3.40" gem "launchy", "~> 3.0" gem "rack-test", "~> 2.1", require: "rack/test" gem "rails-controller-testing", "~> 1.0" - gem "mocha", "~> 2.4", require: false + gem "mocha", "~> 2.5", require: false gem "shoulda-context", "~> 3.0.0.rc1" - gem "shoulda-matchers", "~> 6.2" - gem "selenium-webdriver", "~> 4.22" - gem "webmock", "~> 3.23" + gem "shoulda-matchers", "~> 6.4" + gem "selenium-webdriver", "~> 4.26" + gem "webmock", "~> 3.24" gem "simplecov", "~> 0.22", require: false gem "simplecov-cobertura", "~> 2.1", require: false gem "aggregate_assertions", "~> 0.2.0" gem "minitest-gcstats", "~> 1.3" gem "minitest-reporters", "~> 1.7" + gem "gem_server_conformance", "~> 0.1.4" end + +gem "avo_upgrade", "~> 0.1.1", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index 2dc46337f37..6baade10b9f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,51 +1,74 @@ +GEM + remote: https://packager.dev/avo-hq/ + specs: + avo-advanced (3.14.0) + avo (= 3.14.0) + avo-dynamic_filters (= 3.14.0) + avo-pro (= 3.14.0) + zeitwerk (>= 2.6.12) + avo-dashboards (3.14.0) + avo (= 3.14.0) + turbo-rails + view_component (>= 3.7.0) + zeitwerk (>= 2.6.12) + avo-dynamic_filters (3.14.0) + avo (= 3.14.0) + ransack (>= 4.2.0) + view_component (>= 3.7.0) + zeitwerk (>= 2.6.12) + avo-menu (3.14.0) + avo (= 3.14.0) + docile + zeitwerk (>= 2.6.12) + avo-pro (3.14.0) + avo (= 3.14.0) + avo-dashboards (= 3.14.0) + avo-menu (= 3.14.0) + zeitwerk (>= 2.6.12) + GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (7.2.1.1) + actionpack (= 7.2.1.1) + activesupport (= 7.2.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.1.1) + actionpack (= 7.2.1.1) + activejob (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) + mail (>= 2.8.0) + actionmailer (7.2.1.1) + actionpack (= 7.2.1.1) + actionview (= 7.2.1.1) + activejob (= 7.2.1.1) + activesupport (= 7.2.1.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (7.2.1.1) + actionview (= 7.2.1.1) + activesupport (= 7.2.1.1) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.2) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + useragent (~> 0.16) + actiontext (7.2.1.1) + actionpack (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (7.2.1.1) + activesupport (= 7.2.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -53,31 +76,32 @@ GEM active_link_to (1.0.5) actionpack addressable - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + activejob (7.2.1.1) + activesupport (= 7.2.1.1) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (7.2.1.1) + activesupport (= 7.2.1.1) + activerecord (7.2.1.1) + activemodel (= 7.2.1.1) + activesupport (= 7.2.1.1) timeout (>= 0.4.0) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + activestorage (7.2.1.1) + actionpack (= 7.2.1.1) + activejob (= 7.2.1.1) + activerecord (= 7.2.1.1) + activesupport (= 7.2.1.1) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (7.2.1.1) base64 bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) aes_key_wrap (1.1.0) @@ -91,46 +115,47 @@ GEM ffi-compiler (~> 1.0) ast (2.4.2) attr_required (1.0.2) - autoprefixer-rails (10.4.16.0) - execjs (~> 2) - avo (2.51.0) - actionview (>= 6.0) + avo (3.14.0) + actionview (>= 6.1) active_link_to - activerecord (>= 6.0) + activerecord (>= 6.1) + activesupport (>= 6.1) addressable docile - dry-initializer - httparty inline_svg meta-tags - pagy - turbo-rails - turbo_power (~> 0.5.0) - view_component (>= 2.54.0) - zeitwerk (>= 2.6.2) + pagy (>= 7.0.0) + prop_initializer (>= 0.2.0) + turbo-rails (>= 2.0.0) + turbo_power (>= 0.6.0) + view_component (>= 3.7.0) + zeitwerk (>= 2.6.12) + avo_upgrade (0.1.1) + rails (>= 6.0.0) + zeitwerk awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.950.0) - aws-sdk-core (3.201.0) + aws-partitions (1.1008.0) + aws-sdk-core (3.213.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.88.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.156.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-s3 (1.171.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-sqs (1.80.0) - aws-sdk-core (~> 3, >= 3.201.0) + aws-sdk-sqs (1.88.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sigv4 (1.8.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.2.0) bcrypt (3.1.20) - benchmark-ips (2.12.0) + benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -144,11 +169,11 @@ GEM bloomer (1.0.0) bitarray msgpack - bootsnap (1.18.3) + bootsnap (1.18.4) msgpack (~> 1.2) - brakeman (6.1.2) + brakeman (6.2.2) racc - browser (6.0.0) + browser (6.1.0) builder (3.3.0) byebug (11.1.3) capybara (3.40.0) @@ -161,11 +186,11 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) cbor (0.5.9.8) - chartkick (5.0.7) + chartkick (5.1.2) childprocess (5.0.0) choice (0.2.0) chunky_png (1.4.0) - clearance (2.7.2) + clearance (2.9.2) actionmailer (>= 5.0) activemodel (>= 5.0) activerecord (>= 5.0) @@ -175,7 +200,7 @@ GEM railties (>= 5.0) coderay (1.1.3) compact_index (0.15.0) - concurrent-ruby (1.3.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) cose (1.3.0) cbor (~> 0.5.9) @@ -184,97 +209,121 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.17.1) + css_parser (1.19.1) addressable csv (3.3.0) dalli (3.2.8) - dartsass-sprockets (3.1.0) - railties (>= 4.0.0) - sassc-embedded (~> 1.69) - sprockets (> 3.0) - sprockets-rails - tilt - datadog (2.1.0) - debase-ruby_core_source (= 3.3.1) - libdatadog (~> 9.0.0.1.0) - libddwaf (~> 1.14.0.0.0) + datadog (2.7.0) + datadog-ruby_core_source (~> 3.3) + libdatadog (~> 14.1.0.1.0) + libddwaf (~> 1.15.0.0.0) msgpack - datadog-ci (1.1.0) - datadog (~> 2.0) + datadog-ci (1.8.1) + datadog (~> 2.4) msgpack - date (3.3.4) - dead_end (4.0.0) - debase-ruby_core_source (3.3.1) - derailed_benchmarks (2.1.2) + datadog-ruby_core_source (3.3.6) + date (3.4.0) + derailed_benchmarks (2.2.1) + base64 benchmark-ips (~> 2) - dead_end - get_process_mem (~> 0) + bigdecimal + drb + get_process_mem heapy (~> 0) + logger memory_profiler (>= 0, < 2) mini_histogram (>= 0.3.0) + mutex_m + ostruct rack (>= 1) rack-test rake (> 10, < 14) - ruby-statistics (>= 2.1) + ruby-statistics (>= 4.0.1) + ruby2_keywords thor (>= 0.19, < 2) - discard (1.3.0) - activerecord (>= 4.2, < 8) - docile (1.4.0) - dogstatsd-ruby (5.6.1) + diff-lcs (1.5.1) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) + docile (1.4.1) + dogstatsd-ruby (5.6.3) domain_name (0.6.20240107) - dotenv (3.1.2) - dotenv-rails (3.1.2) - dotenv (= 3.1.2) + dotenv (3.1.4) + dotenv-rails (3.1.4) + dotenv (= 3.1.4) railties (>= 6.1) drb (2.2.1) - dry-initializer (3.1.1) email_validator (2.2.4) activemodel erubi (1.13.0) et-orbi (1.2.11) tzinfo - execjs (2.9.1) - factory_bot (6.4.5) + factory_bot (6.5.0) activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) railties (>= 5.0.0) - faraday (2.9.2) - faraday-net_http (>= 2.0, < 3.2) + faraday (2.12.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-follow_redirects (0.3.0) faraday (>= 1, < 3) - faraday-net_http (3.1.0) - net-http + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (3.4.0) + net-http (>= 0.5.0) + faraday-restrict-ip-addresses (0.3.0) + faraday (> 1.0, < 3.0) faraday-retry (2.2.1) faraday (~> 2.0) faraday_middleware-aws-sigv4 (1.0.1) aws-sigv4 (~> 1.0) faraday (>= 2.0, < 3) ffi (1.17.0) + ffi (1.17.0-aarch64-linux-gnu) + ffi (1.17.0-arm64-darwin) + ffi (1.17.0-x86_64-darwin) + ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.0-x86_64-linux-musl) ffi-compiler (1.3.2) ffi (>= 1.15.5) rake - fugit (1.11.0) + fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) - get_process_mem (0.2.7) + gem_server_conformance (0.1.4) + rspec (~> 3.0) + get_process_mem (1.0.0) + bigdecimal (>= 2.0) ffi (~> 1.0) globalid (1.2.1) activesupport (>= 6.1) - good_job (3.29.5) + good_job (3.99.1) activejob (>= 6.0.0) activerecord (>= 6.0.0) concurrent-ruby (>= 1.0.2) fugit (>= 1.1) railties (>= 6.0.0) thor (>= 0.14.1) - google-protobuf (4.27.2) + google-protobuf (4.28.3) + bigdecimal + rake (>= 13) + google-protobuf (4.28.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.28.3-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.3-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.28.3-x86_64-linux) bigdecimal rake (>= 13) gravtastic (3.2.6) - groupdate (6.4.0) - activesupport (>= 6.1) - hashdiff (1.1.0) + groupdate (6.5.1) + activesupport (>= 7) + hashdiff (1.1.1) hashie (5.0.0) heapy (0.2.0) thor @@ -287,32 +336,28 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.6) + http-cookie (1.0.7) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) - httparty (0.22.0) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) - importmap-rails (2.0.1) + importmap-rails (2.0.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - inline_svg (1.9.0) + inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.13.2) + irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) job-iteration (1.5.1) activejob (>= 5.2) - json (2.7.2) - json-jwt (1.16.6) + json (2.8.2) + json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap base64 @@ -333,13 +378,14 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.3) - launchdarkly-server-sdk (8.6.0) + launchdarkly-server-sdk (8.8.2) concurrent-ruby (~> 1.1) http (>= 4.4.0, < 6.0.0) json (~> 2.3) ld-eventsource (= 2.2.2) observer (~> 0.1.2) semantic (~> 1.6) + zlib (~> 3.1) launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -353,8 +399,18 @@ GEM letter_opener (~> 1.9) railties (>= 6.1) rexml - libdatadog (9.0.0.1.0) - libddwaf (1.14.0.0.0) + libdatadog (14.1.0.1.0) + libdatadog (14.1.0.1.0-aarch64-linux) + libdatadog (14.1.0.1.0-x86_64-linux) + libddwaf (1.15.0.0.0) + ffi (~> 1.0) + libddwaf (1.15.0.0.0-aarch64-linux) + ffi (~> 1.0) + libddwaf (1.15.0.0.0-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.15.0.0.0-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.15.0.0.0-x86_64-linux) ffi (~> 1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -362,11 +418,12 @@ GEM llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) - logger (1.6.0) - loofah (2.22.0) + local_time (3.0.2) + logger (1.6.1) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lookbook (2.3.2) + lookbook (2.3.4) activemodel css_parser htmlbeautifier (~> 1.3) @@ -383,7 +440,7 @@ GEM net-imap net-pop net-smtp - maintenance_tasks (2.7.1) + maintenance_tasks (2.8.0) actionpack (>= 6.0) activejob (>= 6.0) activerecord (>= 6.0) @@ -393,14 +450,14 @@ GEM zeitwerk (>= 2.6.2) marcel (1.0.4) matrix (0.4.2) - memory_profiler (1.0.2) - meta-tags (2.21.0) - actionpack (>= 6.0.0, < 7.2) + memory_profiler (1.1.0) + meta-tags (2.22.1) + actionpack (>= 6.0.0, < 8.1) method_source (1.1.0) mini_histogram (0.3.1) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.24.1) + mini_portile2 (2.8.8) + minitest (5.25.1) minitest-gcstats (1.3.1) minitest (~> 5.0) minitest-reporters (1.7.1) @@ -408,18 +465,19 @@ GEM builder minitest (>= 5.0) ruby-progressbar - minitest-retry (0.2.2) + minitest-retry (0.2.3) minitest (>= 5.0) - mocha (2.4.0) + mocha (2.5.0) ruby2_keywords (>= 0.0.5) - msgpack (1.7.2) + msgpack (1.7.5) multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) + multipart-post (2.4.1) mutex_m (0.2.0) - net-http (0.4.1) + net-http (0.5.0) uri - net-imap (0.4.14) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) @@ -428,10 +486,18 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.0) - nokogiri (1.16.6) + nio4r (2.7.3) + nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.16.7-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.7-x86_64-linux) + racc (~> 1.4) oauth2 (2.0.9) faraday (>= 0.17.3, < 3.0) jwt (>= 1.0, < 3.0) @@ -440,7 +506,7 @@ GEM snaky_hash (~> 2.0) version_gem (~> 1.1) observer (0.1.2) - octokit (9.1.0) + octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) omniauth (2.1.2) @@ -456,7 +522,7 @@ GEM omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) - openid_connect (2.3.0) + openid_connect (2.3.1) activemodel attr_required (>= 1.0.0) email_validator @@ -469,48 +535,66 @@ GEM tzinfo validate_url webfinger (~> 2.0) - opensearch-ruby (3.3.0) + opensearch-ruby (3.4.0) faraday (>= 1.0, < 3) multi_json (>= 1.0) openssl (3.2.0) openssl-signature_algorithm (1.3.0) openssl (> 2.0) optimist (3.1.0) - pagy (8.4.0) - parallel (1.25.1) - parser (3.3.3.0) + ostruct (0.6.0) + pagy (8.6.3) + parallel (1.26.3) + parser (3.3.5.0) ast (~> 2.4.1) racc - pg (1.5.6) + pg (1.5.9) pg_query (5.1.0) google-protobuf (>= 3.22.3) - pghero (3.5.0) - activerecord (>= 6) - phlex (1.10.2) - phlex-rails (1.2.1) - phlex (~> 1.10.0) - railties (>= 6.1, < 8) - pp (0.5.0) + pghero (3.6.1) + activerecord (>= 6.1) + phlex (1.11.0) + phlex-rails (1.2.2) + phlex (>= 1.10, < 2) + railties (>= 6.1, < 9) + pp (0.6.1) prettyprint prettyprint (0.2.0) + prop_initializer (0.2.0) + zeitwerk (>= 2.6.18) + propshaft (1.1.0) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + railties (>= 7.0.0) prosopite (1.4.2) + protobug (0.1.0) + protobug_googleapis_field_behavior_protos (0.1.0) + protobug (= 0.1.0) + protobug_well_known_protos (= 0.1.0) + protobug_sigstore_protos (0.1.0) + protobug (= 0.1.0) + protobug_googleapis_field_behavior_protos (= 0.1.0) + protobug_well_known_protos (= 0.1.0) + protobug_well_known_protos (0.1.0) + protobug (= 0.1.0) pry (0.14.1) coderay (~> 1.1) method_source (~> 1.0) pry-byebug (3.10.1) byebug (~> 11.0) pry (>= 0.13, < 0.15) - psych (5.1.2) + psych (5.2.0) stringio - public_suffix (6.0.0) - puma (6.4.2) + public_suffix (6.0.1) + puma (6.4.3) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) pwned (2.3.0) raabro (1.4.0) - racc (1.8.0) - rack (3.1.6) + racc (1.8.1) + rack (3.1.8) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-oauth2 (2.2.1) @@ -523,29 +607,28 @@ GEM rack-protection (4.0.0) base64 (>= 0.1.0) rack (>= 3.0.0, < 4) + rack-sanitizer (2.0.3) + rack (>= 1.0, < 4.0) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rack-utf8_sanitizer (1.9.1) - rack (>= 1.0, < 4.0) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.1.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (7.2.1.1) + actioncable (= 7.2.1.1) + actionmailbox (= 7.2.1.1) + actionmailer (= 7.2.1.1) + actionpack (= 7.2.1.1) + actiontext (= 7.2.1.1) + actionview (= 7.2.1.1) + activejob (= 7.2.1.1) + activemodel (= 7.2.1.1) + activerecord (= 7.2.1.1) + activestorage (= 7.2.1.1) + activesupport (= 7.2.1.1) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 7.2.1.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -562,23 +645,27 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.9) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - rails_semantic_logger (4.16.0) + rails_semantic_logger (4.17.0) rack railties (>= 5.1) - semantic_logger (~> 4.13) - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) - irb + semantic_logger (~> 4.16) + railties (7.2.1.1) + actionpack (= 7.2.1.1) + activesupport (= 7.2.1.1) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) + ransack (4.2.1) + activerecord (>= 6.1.5) + activesupport (>= 6.1.5) + i18n rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -590,87 +677,98 @@ GEM psych (>= 4.0.0) redcarpet (3.6.0) regexp_parser (2.9.2) - reline (0.5.9) + reline (0.5.11) io-console (~> 0.5) - rexml (3.3.1) - strscan + rexml (3.3.9) roadie (5.2.1) css_parser (~> 1.4) nokogiri (~> 1.15) - roadie-rails (3.2.0) - railties (>= 5.1, < 8.0) + roadie-rails (3.3.0) + railties (>= 5.1, < 8.1) roadie (~> 5.0) rotp (6.3.0) - rouge (4.3.0) + rouge (4.4.0) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rubocop (1.64.1) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) + rubocop (1.66.1) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.4, < 3.0) + rubocop-ast (>= 1.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) + rubocop-ast (1.32.3) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) rubocop-factory_bot (2.26.1) rubocop (~> 1.61) - rubocop-minitest (0.35.0) + rubocop-minitest (0.36.0) rubocop (>= 1.61, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-performance (1.21.1) + rubocop-performance (1.22.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.1) + rubocop-rails (2.26.2) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-graphviz (1.2.5) rexml ruby-magic (0.6.0) mini_portile2 (~> 2.8) ruby-progressbar (1.13.0) - ruby-statistics (3.0.2) + ruby-statistics (4.0.1) ruby2_keywords (0.0.5) rubyzip (2.3.2) safety_net_attestation (0.4.0) jwt (~> 2.0) - sass-embedded (1.72.0) - google-protobuf (>= 3.25, < 5.0) - rake (>= 13.0.0) - sassc-embedded (1.70.1) - sass-embedded (~> 1.70) sawyer (0.9.2) addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) - searchkick (5.3.1) + searchkick (5.4.0) activemodel (>= 6.1) hashie - selenium-webdriver (4.22.0) + securerandom (0.3.2) + selenium-webdriver (4.26.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) semantic (1.6.1) - semantic_logger (4.15.0) + semantic_logger (4.16.0) concurrent-ruby (~> 1.0) shoryuken (6.2.1) aws-sdk-core (>= 2) concurrent-ruby thor shoulda-context (3.0.0.rc1) - shoulda-matchers (6.2.0) + shoulda-matchers (6.4.0) activesupport (>= 5.2.0) + sigstore (0.1.1) + net-http + protobug_sigstore_protos (~> 0.1.0) + uri simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -684,32 +782,30 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.5.1) - actionpack (>= 6.1) - activesupport (>= 6.1) - sprockets (>= 3.0.0) - statsd-instrument (3.8.0) - stimulus-rails (1.3.3) + statsd-instrument (3.9.7) + stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.1) - strong_migrations (2.0.0) + stringio (3.1.2) + strong_migrations (2.1.0) activerecord (>= 6.1) - strscan (3.1.0) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) faraday (~> 2.0) faraday-follow_redirects - tailwindcss-rails (2.6.1) + tailwindcss-rails (3.0.0) railties (>= 7.0.0) terser (1.2.3) execjs (>= 0.3.0, < 3) - thor (1.3.1) tilt (2.3.0) - timeout (0.4.1) + tailwindcss-ruby + tailwindcss-ruby (3.4.14) + tailwindcss-ruby (3.4.14-aarch64-linux) + tailwindcss-ruby (3.4.14-arm64-darwin) + tailwindcss-ruby (3.4.14-x86_64-darwin) + tailwindcss-ruby (3.4.14-x86_64-linux) + thor (1.3.2) + timeout (0.4.2) timescaledb (0.3.0) activerecord activesupport @@ -719,27 +815,27 @@ GEM bindata (~> 2.4) openssl (> 2.0) openssl-signature_algorithm (~> 1.0) - turbo-rails (1.5.0) + turbo-rails (2.0.11) actionpack (>= 6.0.0) - activejob (>= 6.0.0) railties (>= 6.0.0) - turbo_power (0.5.0) - turbo-rails (~> 1.3) + turbo_power (0.6.2) + turbo-rails (>= 1.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) unpwn (1.0.0) bloomer (~> 1.0) pwned (~> 2.0) - uri (0.13.0) + uri (1.0.2) user_agent_parser (2.18.0) + useragent (0.16.10) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix validates_formatting_of (0.9.0) activemodel version_gem (1.1.1) - view_component (3.12.1) + view_component (3.14.0) activesupport (>= 5.2.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) @@ -756,12 +852,11 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.23.1) + webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.1) - websocket (1.2.10) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -769,92 +864,106 @@ GEM rexml xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.36) - zeitwerk (2.6.16) + yard (0.9.37) + zeitwerk (2.7.1) + zlib (3.1.1) PLATFORMS + aarch64-linux + arm64-darwin + arm64-linux ruby + x86_64-darwin + x86_64-linux + x86_64-linux-musl DEPENDENCIES aggregate_assertions (~> 0.2.0) amazing_print (~> 1.6) - autoprefixer-rails (~> 10.4) - avo (~> 2.51) - aws-sdk-s3 (~> 1.156) - aws-sdk-sqs (~> 1.80) + avo (~> 3.13) + avo-advanced (~> 3.14)! + avo_upgrade (~> 0.1.1) + aws-sdk-s3 (~> 1.171) + aws-sdk-sqs (~> 1.88) bcrypt (~> 3.1) better_html (~> 2.1) bootsnap (~> 1.18) - brakeman (~> 6.1) - browser (~> 6.0) + brakeman (~> 6.2) + browser (~> 6.1) capybara (~> 3.40) - chartkick (~> 5.0) - clearance (~> 2.7) + chartkick (~> 5.1) + clearance (~> 2.9) compact_index (~> 0.15.0) csv (~> 3.3) dalli (~> 3.2) - dartsass-sprockets (~> 3.1) - datadog (~> 2.1) - datadog-ci (~> 1.1) - derailed_benchmarks (~> 2.1) - discard (~> 1.3) - dogstatsd-ruby (~> 5.5) + datadog (~> 2.7) + datadog-ci (~> 1.8) + derailed_benchmarks (~> 2.2) + discard (~> 1.4) + dogstatsd-ruby (~> 5.6) dotenv-rails (~> 3.1) factory_bot_rails (~> 6.4) - faraday (~> 2.9) + faraday (~> 2.12) + faraday-multipart (~> 1.0) + faraday-restrict-ip-addresses (~> 0.3.0) faraday-retry (~> 2.2) faraday_middleware-aws-sigv4 (~> 1.0) - good_job (~> 3.29) - google-protobuf (~> 4.27) + gem_server_conformance (~> 0.1.4) + good_job (~> 3.99) + google-protobuf (~> 4.28) gravtastic (~> 3.2) - groupdate (~> 6.2) + groupdate (~> 6.5) honeybadger (~> 5.5.1) http_accept_language (~> 2.1) importmap-rails (~> 2.0) kaminari (~> 1.2) - launchdarkly-server-sdk (~> 8.6) + launchdarkly-server-sdk (~> 8.8) launchy (~> 3.0) letter_opener (~> 1.10) letter_opener_web (~> 3.0) listen (~> 3.9) + local_time (~> 3.0) lookbook (~> 2.3) mail (~> 2.8) - maintenance_tasks (~> 2.7) - memory_profiler (~> 1.0) - minitest (~> 5.24) + maintenance_tasks (~> 2.8) + memory_profiler (~> 1.1) + minitest (~> 5.25) minitest-gcstats (~> 1.3) minitest-reporters (~> 1.7) - minitest-retry (~> 0.2.2) - mocha (~> 2.4) + minitest-retry (~> 0.2.3) + mocha (~> 2.5) observer (~> 0.1.2) - octokit (~> 9.1) + octokit (~> 9.2) omniauth (~> 2.1) omniauth-github (~> 2.0) omniauth-rails_csrf_protection (~> 1.0) openid_connect (~> 2.3) - opensearch-ruby (~> 3.3) + opensearch-ruby (~> 3.4) + pagy (~> 8.4) pg (~> 1.5) pg_query (~> 5.1) - pghero (~> 3.5) + pghero (~> 3.6) phlex-rails (~> 1.2) - pp (= 0.5.0) + pp (= 0.6.1) + prop_initializer (~> 0.2) + propshaft (~> 1.1.0) prosopite (~> 1.4) pry-byebug (~> 3.10) puma (~> 6.4) - pundit (~> 2.3) + pundit (~> 2.4) rack (~> 3.1) rack-attack (~> 6.6) + rack-sanitizer (~> 2.0) rack-test (~> 2.1) - rack-utf8_sanitizer (~> 1.8) - rackup (~> 2.1) - rails (~> 7.1.0, >= 7.1.3.2) + rackup (~> 2.2) + rails (~> 7.2.1) rails-controller-testing (~> 1.0) rails-erd (~> 1.7) rails-i18n (~> 7.0) - rails_semantic_logger (~> 4.16) + rails_semantic_logger (~> 4.17) rbtrace (~> 0.5.1) rdoc (~> 6.7) - roadie-rails (~> 3.2) + roadie-rails (~> 3.3) rotp (~> 6.2) rqrcode (~> 2.1) rubocop (~> 1.64) @@ -864,42 +973,41 @@ DEPENDENCIES rubocop-performance (~> 1.21) rubocop-rails (~> 2.25) ruby-magic (~> 0.6) - searchkick (~> 5.3) - selenium-webdriver (~> 4.22) + searchkick (~> 5.4) + selenium-webdriver (~> 4.26) shoryuken (~> 6.2) shoulda-context (~> 3.0.0.rc1) - shoulda-matchers (~> 6.2) + shoulda-matchers (~> 6.4) + sigstore (~> 0.1.1) simplecov (~> 0.22) simplecov-cobertura (~> 2.1) - sprockets-rails (~> 3.5) - statsd-instrument (~> 3.8) + statsd-instrument (~> 3.9) stimulus-rails (~> 1.3) - strong_migrations (~> 2.0) - tailwindcss-rails (~> 2.6) - terser (~> 1.2) - timescaledb (~> 0.3.0) + strong_migrations (~> 2.1) + tailwindcss-rails (~> 3.0) + timescaledb (~> 0.3) toxiproxy (~> 2.0) unpwn (~> 1.0) user_agent_parser (~> 2.18) validates_formatting_of (~> 0.9) - view_component (~> 3.12) + view_component (~> 3.14.0) webauthn (~> 3.1) - webmock (~> 3.23) + webmock (~> 3.24) xml-simple (~> 1.1) CHECKSUMS - actioncable (7.1.3.4) sha256=787ba8651caaa93d5c161f0d1110105300974be65e89483071146fc42d4bd310 - actionmailbox (7.1.3.4) sha256=a3fd3019a44597e49ae18b4ed5c68e0f21c1d1b389bbcc10be357e205a83cad0 - actionmailer (7.1.3.4) sha256=1f196096740587b08ef935db8a672971f448cadb8299e3d9a7bc24088a2a0351 - actionpack (7.1.3.4) sha256=dcafc71bec6a975c3984a1ed8e698e2f9afeeb441c838766c16c29633705edd2 - actiontext (7.1.3.4) sha256=84964dae95a3c99819d42641084f21e28de502fcefa6efb9df3805d6c439b784 - actionview (7.1.3.4) sha256=41fcf5242dec11e100a0ba3d3717612c6534e8571c8a290a5b2a950aa58b615b + actioncable (7.2.1.1) sha256=56a57b5a3615cabe754d6cf6ad4ab7a2aa10209638a94b56598311c411869980 + actionmailbox (7.2.1.1) sha256=839f3b406f049a9dafd8e00695282025e2e1d9d7caafc852fd6156609d262346 + actionmailer (7.2.1.1) sha256=95aeb7eb76dc0a90917c7a3a7ad3e01464a43bf81d6c51022045ae4c7fdc627b + actionpack (7.2.1.1) sha256=a7c1654012de62713252459eb15f3918f7aa119d906a15f74e76a0ed4e8e5bc7 + actiontext (7.2.1.1) sha256=66675be781ea2a75c1958698c4083742608696e1116a9935602a6caba90305d5 + actionview (7.2.1.1) sha256=a0136e499a54eb394d97a9fc6960a2b2920ddd55911f94b09cbe7b9295cd90c9 active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba - activejob (7.1.3.4) sha256=3f8aeef0fdfb2dd65f9a663828dbcc8ca187e70ef0c5a773c5fe4dd67e040f62 - activemodel (7.1.3.4) sha256=f4c838ea76dfca8967e433ac89603342ae20b65dd61366e62f07120a08e1ad72 - activerecord (7.1.3.4) sha256=784eeca4d6f23391d445552d6675a47c594555361c3b042108d29f0c7b9230f2 - activestorage (7.1.3.4) sha256=f2020ea0a77e105e480a9a15251c91d615eecb4b28a1a80968d6fb6a5dcb0a2e - activesupport (7.1.3.4) sha256=455bbc43d82e5ba20daa25f0888b80c9f7e2d80ca0cc96cea3e6acfec3e40309 + activejob (7.2.1.1) sha256=27cd7e7c52a1355d90761212b32614249a0ea0164bccec1fb5a15ef26e47da28 + activemodel (7.2.1.1) sha256=315d5d2e3d3a5f850a9bea0a69d175bdbf3c5a06a98014006dde73dc83dc4603 + activerecord (7.2.1.1) sha256=c124a4e4ddfa93e7f198bfdb464dcb8cc5908c89e9c22ec4248ec52ce719aa6b + activestorage (7.2.1.1) sha256=f699a54809413424c7aafcb7a0fa4a2ea56366e0e92603bc80d51168d5ac6bbb + activesupport (7.2.1.1) sha256=bd4c1b0e5f758c8ccd16250f43b32925e33521e3c0d77c1574588f0d02f83bb2 addressable (2.8.7) sha256=462986537cf3735ab5f3c0f557f14155d778f4b43ea4f485a9deb9c8f7c58232 aes_key_wrap (1.1.0) sha256=b935f4756b37375895db45669e79dfcdc0f7901e12d4e08974d5540c8e0776a5 aggregate_assertions (0.2.0) sha256=9bc51a48323a8e7b82f47cc38d48132817247345e5a8713686c9d65b25daca9e @@ -909,280 +1017,319 @@ CHECKSUMS argon2 (2.3.0) sha256=980ef65172bf512ad37b6cbb0d61eef40b6dccab6a7db4e70557527e1dce9557 ast (2.4.2) sha256=1e280232e6a33754cde542bc5ef85520b74db2aac73ec14acef453784447cc12 attr_required (1.0.2) sha256=f0ebfc56b35e874f4d0ae799066dbc1f81efefe2364ca3803dc9ea6a4de6cb99 - autoprefixer-rails (10.4.16.0) sha256=40c4b14d6f26f66026cd0d4631baf18d6c56aab425b36059c8abbda17f19a706 - avo (2.51.0) sha256=0d5785cda01b5b0d2575e7419cda4dc7a5d7805068f160d48ecc7458ee74ec03 + avo (3.14.0) sha256=ae8744b3bde7c9b3d41869e58214abf288d7ef6f230420746f012df083f1c0be + avo-advanced (3.14.0) sha256=9b4a450819e7ea4aa2b25ff07d4e6f0bd36bcb6c99e86469a2fd1be733b9fe17 + avo-dashboards (3.14.0) sha256=5b2c30fee710fdfec6d47d940d5018cb1afcd2020720e5ef436001dc2ee6387b + avo-dynamic_filters (3.14.0) sha256=05fd9e5846ad247310fbffed61b40be310d9334646e8d59afe6c724faff5b28e + avo-menu (3.14.0) sha256=f07243d2a52921d28718d7d9bdddb11eaebdc0dd5b07deed7a7fb767550be554 + avo-pro (3.14.0) sha256=0a20743f5c5e685a9088c9b753bc8419a68aac57f7fa2966485e9ceb61a82c5f + avo_upgrade (0.1.1) sha256=8d841083b9956392f5c8fe195f25bec0d139e3646d276f8a59e66b7d2e9ebf30 awrence (1.2.1) sha256=dd1d214c12a91f449d1ef81d7ee3babc2816944e450752e7522c65521872483e aws-eventstream (1.3.0) sha256=f1434cc03ab2248756eb02cfa45e900e59a061d7fbdc4a9fd82a5dd23d796d3f - aws-partitions (1.950.0) sha256=b980ec8f8b014be08fe0f65182dee16afc06d04a2175177c17fa45eee44147ec - aws-sdk-core (3.201.0) sha256=18fda4d14362eea4cb0bff54bc68222bd46b5e705ec84468954aaf7cf9a8360c - aws-sdk-kms (1.88.0) sha256=13588d90df1eece81f6d79bd304b3857dc3168e7ea75c933b3b835cfe8a0e309 - aws-sdk-s3 (1.156.0) sha256=9302da1d1a70363308854d5065035f6c72cf8b8af895d8789487cd5c6b076a46 - aws-sdk-sqs (1.80.0) sha256=ae0bdced7aa958ddd99d28f709abe244839d58fbf1f2a628bcd9a5629a7444c2 - aws-sigv4 (1.8.0) sha256=84dd99768b91b93b63d1d8e53ee837cfd06ab402812772a7899a78f9f9117cbc + aws-partitions (1.1008.0) sha256=6fb5e6b843ea1169480c804fc861a5de7407762097de75cf4734fbcd35466227 + aws-sdk-core (3.213.0) sha256=6ca685be1d72d61776fdaaddf3c293e45a472ff0dd0b624880e7813d0c82db19 + aws-sdk-kms (1.95.0) sha256=2ae508c642ddc59baa1296229108e9601a2fa00e57cf7a2153c9488f0587fd5e + aws-sdk-s3 (1.171.0) sha256=94a2210c20f6102d8867937b021ef40683aa351e28912ac9cc6ef20509f85f4f + aws-sdk-sqs (1.88.0) sha256=3e4e022b9af1796eb87bb368a8bb2001ebcad3b5025d76aa9ba731acea01a2eb + aws-sigv4 (1.10.1) sha256=8a140753f34de18125686b11e7adaed4ca3db06dfb50a478993bd437f7a203bb base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507 bcrypt (3.1.20) sha256=8410f8c7b3ed54a3c00cd2456bf13917d695117f033218e2483b2e40b0784099 - benchmark-ips (2.12.0) sha256=09dd4d5be05db42470e7e7b01be7310564073a054e35d9d9ec7840c523f3dbcb + benchmark-ips (2.14.0) sha256=b72bc8a65d525d5906f8cd94270dccf73452ee3257a32b89fbd6684d3e8a9b1d better_html (2.1.1) sha256=046c3551d1488a3f2939a7cac6fabf2bde08c32e135c91fcd683380118e5af55 bigdecimal (3.1.8) sha256=a89467ed5a44f8ae01824af49cbc575871fa078332e8f77ea425725c1ffe27be bindata (2.5.0) sha256=29dccb8ba1cc9de148f24bb88930840c62db56715f0f80eccadd624d9f3d2623 bitarray (1.2.0) sha256=7f9f31fadbd87bf51544cf13058e81cd6ec408ff40f127902cef3d6767b23f11 bloomer (1.0.0) sha256=57a0d3a78628db9a92c6723f06c67697e420abcdb05aa757c6dfae607251d272 - bootsnap (1.18.3) sha256=d7b70de761e2fb1d63d21dd941b393c881c5cab5575211369cede788dfc034eb - brakeman (6.1.2) sha256=7716769c18f2c4a52d7a74d2cb5a614be0c46d8aad3fbe7ca089dbb7c98bd4d3 - browser (6.0.0) sha256=0399f0f12c925e529aa995b096a3824384e00ea2c7241fbb4b707d2a25e87920 + bootsnap (1.18.4) sha256=ac4c42af397f7ee15521820198daeff545e4c360d2772c601fbdc2c07d92af55 + brakeman (6.2.2) sha256=d502d653699f4d451b21225ff4d19a9ec9345d23eaab5576e246185ffd7bf618 + browser (6.1.0) sha256=b9104e9d094800ec8243ad787f2289ea25b23921eb88c315cea53c89305424c7 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f byebug (11.1.3) sha256=2485944d2bb21283c593d562f9ae1019bf80002143cc3a255aaffd4e9cf4a35b capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef cbor (0.5.9.8) sha256=9ee097fc58d9bc5e406d112cd2d4e112c7354ec16f8b6ff34e4732c1e44b4eb7 - chartkick (5.0.7) sha256=fe52cfd34a51ff0c42dabe26c59a827ffc5c74de56816eddcb74b7c639938893 + chartkick (5.1.2) sha256=1bb981ebb567c6bc6d1f555fba3dc5b4a7d1e7d170ea7d6980b4badf0032305a childprocess (5.0.0) sha256=0746b7ab1d6c68156e64a3767631d7124121516192c0492929a7f0af7310d835 choice (0.2.0) sha256=a19617f7dfd4921b38a85d0616446620de685a113ec6d1ecc85bdb67bf38c974 chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe - clearance (2.7.2) sha256=68bb326c1045cf71e608b1a3a90b8144f06018458384769456ed335c6c254822 + clearance (2.9.2) sha256=d4981644d7b78ec26b95ed72b9c65a22b4ce2efe7ffb81f9dd4c4b43cf03d159 coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b compact_index (0.15.0) sha256=5c6c404afca8928a7d9f4dde9524f6e1610db17e675330803055db282da84a8b - concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a + concurrent-ruby (1.3.4) sha256=d4aa926339b0a86b5b5054a0a8c580163e6f5dcbdfd0f4bb916b1a2570731c32 connection_pool (2.4.1) sha256=0f40cf997091f1f04ff66da67eabd61a9fe0d4928b9a3645228532512fab62f4 cose (1.3.0) sha256=63247c66a5bc76e53926756574fe3724cc0a88707e358c90532ae2a320e98601 crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49 crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d - css_parser (1.17.1) sha256=eb730f2d26591a843e52bd3d0efd76abdfeec8bad728e0b2ac821fc10bb018e6 + css_parser (1.19.1) sha256=1940dce01e3b9be18d6880e6d65162d984cc04ff28998cf4759beb999275209e csv (3.3.0) sha256=0bbd1defdc31134abefed027a639b3723c2753862150f4c3ee61cab71b20d67d dalli (3.2.8) sha256=2e63595084d91fae2655514a02c5d4fc0f16c0799893794abe23bf628bebaaa5 - dartsass-sprockets (3.1.0) sha256=c238ec9f7f496489ac5a7813cd1f83d1e077a1826921acefc7e290a521b7a20a - datadog (2.1.0) sha256=6a9be58bb3dc01e1890d1b3452cef262de925bda9ad0bfdb8bb854f5f9384e0b - datadog-ci (1.1.0) sha256=15153b9f15deb5e55e81c91aaff6911c8dc7f4e2f139f80e803d079890739fbd - date (3.3.4) sha256=971f2cb66b945bcbea4ddd9c7908c9400b31a71bc316833cb42fa584b59d3291 - dead_end (4.0.0) sha256=695c8438993bb4c5415b1618a1b6e0afcae849ef2812fb8cb3846723904307eb - debase-ruby_core_source (3.3.1) sha256=ed904cae290edf0cf274ad707f8981bf1cefad8081e78d4bb71be2a483bc2c08 - derailed_benchmarks (2.1.2) sha256=eaadc6206ceeb5538ff8f5e04a0023d54ebdd95d04f33e8960fb95a5f189a14f - discard (1.3.0) sha256=55218997ca4dc11f0594f1bb2d1196c4a959ceb562f1ab6490130233598dda67 - docile (1.4.0) sha256=5f1734bde23721245c20c3d723e76c104208e1aa01277a69901ce770f0ebb8d3 - dogstatsd-ruby (5.6.1) sha256=615dd1328c57ab66fb48cbf83b38ce2060d8353707be0bc3deb0ae960a659982 + datadog (2.7.0) sha256=cea0c125acff6630966a2ad0bc01863ba1e1ff2886b4d38dc29f254f89ad02a2 + datadog-ci (1.8.1) sha256=c461acd83d36b5894716ea7b1c207fd4b7fa103994c0773e3936a68da4dfa594 + datadog-ruby_core_source (3.3.6) sha256=007c72450d3f5838c6d0ae4a6a77e5008bb29dd97d10ea3bf367f978d7c02f36 + date (3.4.0) sha256=2e7fadaded625c9b3e35e254e42068d4bd8b8646ceab0744cbcbcfdafaa0a711 + derailed_benchmarks (2.2.1) sha256=654280664fded41c9cd8fc27fc0fcfaf096023afab90eb4ac1185ba70c5d4439 + diff-lcs (1.5.1) sha256=273223dfb40685548436d32b4733aa67351769c7dea621da7d9dd4813e63ddfe + discard (1.4.0) sha256=6efcd2a53ddf96781f81b825d398f1c88ab88c0faa84e131bea6e16ef95d65d0 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dogstatsd-ruby (5.6.3) sha256=9702f3ddd4dbdbf0073edc4c70ed81dd00aa53677705eaebadcde6717c003b1c domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 - dotenv (3.1.2) sha256=08036c2aa1246590034a83df2adc85b72cb4944692c49da24d8e911f97924478 - dotenv-rails (3.1.2) sha256=66d2ad1a5aca1de742dd8ec2ceed2148675c73617a3b8d7703ef86ed47ca5d6a + dotenv (3.1.4) sha256=6dc502e718ea0d3542673345da05c7a69039840898e251757adb3405d2b35629 + dotenv-rails (3.1.4) sha256=a7d75f6ab3cc7f1b28e7deb0462efb155878e4e87ce3cc6e42ce35bea61c6fe4 drb (2.2.1) sha256=e9d472bf785f558b96b25358bae115646da0dbfd45107ad858b0bc0d935cb340 - dry-initializer (3.1.1) sha256=4d267dea367ccabe498b259c62b909b99d577d6db547d9510561999403546dec email_validator (2.2.4) sha256=5ab238095bec7aef9389f230e9e0c64c5081cdf91f19d6c5cecee0a93af20604 erubi (1.13.0) sha256=fca61b47daefd865d0fb50d168634f27ad40181867445badf6427c459c33cd62 et-orbi (1.2.11) sha256=d26e868cc21db88280a9ec1a50aa3da5d267eb9b2037ba7b831d6c2731f5df64 - execjs (2.9.1) sha256=e8fd066f6df60c8e8fbebc32c6fb356b5212c77374e8416a9019ca4bb154dcfb - factory_bot (6.4.5) sha256=d71dd29bc95f0ec2bf27e3dd9b1b4d557bd534caca744663cb7db4bacf3198be - factory_bot_rails (6.4.3) sha256=ea73ceac1c0ff3dc11fff390bf2ea8a2604066525ed8ecd3b3bc2c267226dcc8 - faraday (2.9.2) sha256=6595edbe3b4663223e52a315f6bf2bca97ea1527bab7e02a926bf8afcf7423a4 + factory_bot (6.5.0) sha256=6374b3a3593b8077ee9856d553d2e84d75b47b912cc24eafea4062f9363d2261 + factory_bot_rails (6.4.4) sha256=139e17caa2c50f098fddf5e5e1f29e8067352024e91ca1186d018b36589e5c88 + faraday (2.12.1) sha256=23f2128bf5d40533806b3a3f0444cca218d76d0af25320db8e782bf42ae0aa6f faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 - faraday-net_http (3.1.0) sha256=1627be414960d0131691190ff524506ba6607402a50fb6eccda9e64ca60f859f + faraday-multipart (1.0.4) sha256=9012021ab57790f7d712f590b48d5f948b19b43cfa11ca83e6459f06090b0725 + faraday-net_http (3.4.0) sha256=a1f1e4cd6a2cf21599c8221595e27582d9936819977bbd4089a601f24c64e54a + faraday-restrict-ip-addresses (0.3.0) sha256=2db7f6da6f59fa73a9d4ffe96b2b46a4b2b2c4d177e44a1962c1921d32ec7279 faraday-retry (2.2.1) sha256=4146fed14549c0580bf14591fca419a40717de0dd24f267a8ec2d9a728677608 faraday_middleware-aws-sigv4 (1.0.1) sha256=a001ea4f687ca1c60bad8f2a627196905ce3dbf285e461dc153240e92eaabe8f ffi (1.17.0) sha256=51630e43425078311c056ca75f961bb3bda1641ab36e44ad4c455e0b0e4a231c + ffi (1.17.0-aarch64-linux-gnu) sha256=228c8fb79e6b018a31c75414115a75ca65f74e8138b2c9c97d22041e4e12f2c1 + ffi (1.17.0-arm64-darwin) sha256=609c874e76614542c6d485b0576e42a7a38ffcdf086612f9a300c4ec3fcd0d12 + ffi (1.17.0-x86_64-darwin) sha256=fdcd48c69db3303ef95aec5c64d6275fcf9878a02c0bec0afddc506ceca0f56b + ffi (1.17.0-x86_64-linux-gnu) sha256=1015e59d5919dd6bbcb0704325b0bd639be664a79b1e2189943ceb18faa34198 + ffi (1.17.0-x86_64-linux-musl) sha256=6573299eedf8dd16668f8a435b72c4236b61bca0279bb73c811e3cbdb395e877 ffi-compiler (1.3.2) sha256=a94f3d81d12caf5c5d4ecf13980a70d0aeaa72268f3b9cc13358bcc6509184a0 - fugit (1.11.0) sha256=addc9cd3031611921d1dbac094de3a645bc8858828639fd035c9cedd3b460bb9 - get_process_mem (0.2.7) sha256=4afd3c3641dd6a817c09806c7d6d509d8a9984512ac38dea8b917426bbf77eba + fugit (1.11.1) sha256=e89485e7be22226d8e9c6da411664d0660284b4b1c08cacb540f505907869868 + gem_server_conformance (0.1.4) sha256=ee404d5405eabcb6f7ab440d2193177375481a77bfa0ec3106165dd6c8e733bb + get_process_mem (1.0.0) sha256=d54024f3bcbf776a4d6e0155ec2b21bc3ba44e2fd158c4c00c80aa8a36e0b4aa globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 - good_job (3.29.5) sha256=3d71236c7e89b9678ba0cf16d6a93b9ee2ff12795959a0c10f77fd52f6479a13 - google-protobuf (4.27.2) sha256=9f69eb20acde6e3cf3cd197c09f38911cd7eed5fdf1bf4d4bce4c55e9dc9966f + good_job (3.99.1) sha256=7d3869d8a8ee8ef7048fee5d746f41c21987b7822c20038a2f773036bef0830a + google-protobuf (4.28.3) sha256=c0ed86d6595257123ea9806dceed6ae158b6f8afde550cda2f565c5fa1df29b5 + google-protobuf (4.28.3-aarch64-linux) sha256=7715ac58bad92f3297d02ed302879f03b6994fedcc63d20ec9bfd16b793c95ee + google-protobuf (4.28.3-arm64-darwin) sha256=cde7fa6b63349c79b1e9d815e6b4e3090bd98b7ac460bb282de3af03c4b9c0d5 + google-protobuf (4.28.3-x86_64-darwin) sha256=f2c3aa765d43bd6aa62f5cb48f7c59e6b49a38dc65332b61625f26f7cb4c37f9 + google-protobuf (4.28.3-x86_64-linux) sha256=2e4a2558b863024b296ac1dbceebb707c95f057a0ae1b5023a16440f209ba05a gravtastic (3.2.6) sha256=ef98abcecf7c402b61cff1ae7c50a2c6d97dd22bac21ea9b421ce05bc03d734f - groupdate (6.4.0) sha256=65940645bf2a48f9b2d10ab7a1d19bdc78f3c89559d8fce39cea3448a15aec54 - hashdiff (1.1.0) sha256=b5465f0e7375f1ee883f53a766ece4dbc764b7674a7c5ffd76e79b2f5f6fc9c9 + groupdate (6.5.1) sha256=a0a78d051a510d61864fe987f00089d8f03739741eadc41e6ad4ea6f786da110 + hashdiff (1.1.1) sha256=c7966316726e0ceefe9f5c6aef107ebc3ccfef8b6db55fe3934f046b2cf0936a hashie (5.0.0) sha256=9d6c4e51f2a36d4616cbc8a322d619a162d8f42815a792596039fc95595603da heapy (0.2.0) sha256=74141e845d61ffc7c1e8bf8b127c8cf94544ec7a1181aec613288682543585ea honeybadger (5.5.1) sha256=22350ccfc9f3bac4bf7c5c733d5263d21dc57adeba16f14054428d9cc63fb56d htmlbeautifier (1.4.3) sha256=b43d08f7e2aa6ae1b5a6f0607b4ed8954c8d4a8e85fd2336f975dda1e4db385b htmlentities (4.3.4) sha256=125a73c6c9f2d1b62100b7c3c401e3624441b663762afa7fe428476435a673da http (5.2.0) sha256=b99ed3c65376e0fd8107647fbaf5a8ab4f66c347d1271fb74cea757e209c6115 - http-cookie (1.0.6) sha256=7713d3196f21ff5ab0944011d7d4e619b62ec082374a52de2193ccfe78924044 + http-cookie (1.0.7) sha256=cb7a399f3344f720b8a0f3b0765f29b62608ebb9c3ce4abf4d6eeff2caf42253 http-form_data (2.3.0) sha256=cc4eeb1361d9876821e31d7b1cf0b68f1cf874b201d27903480479d86448a5f3 http_accept_language (2.1.1) sha256=0043f0d55a148cf45b604dbdd197cb36437133e990016c68c892d49dbea31634 - httparty (0.22.0) sha256=78652a5c9471cf0093d3b2083c2295c9c8f12b44c65112f1846af2b71430fa6c - i18n (1.14.5) sha256=26dcbc05e364b57e27ab430148b3377bc413987d34cc042336271d8f42e9d1b9 - importmap-rails (2.0.1) sha256=e739a6e70c09f797688c6983fa79567ec1edc9becc30d55b3f7cc897b1825586 - inline_svg (1.9.0) sha256=f44c5e3d2e401fd619ad3047b7c8cee384517d855edb1d1fb1a248d3cae535d6 + i18n (1.14.6) sha256=dc229a74f5d181f09942dd60ab5d6e667f7392c4ee826f35096db36d1fe3614c + importmap-rails (2.0.3) sha256=c56764941f9b637791fb87123b38f206f27cc55ef03cb19894f1994184e98cc8 + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.7.2) sha256=f0dccff252f877a4f60d04a4dc6b442b185ebffb4b320ab69212a92b48a7a221 - irb (1.13.2) sha256=e72928d8047a515cd868967ecafbe5388097402449fb8ef658c33db6ccde8117 + irb (1.14.1) sha256=5975003b58d36efaf492380baa982ceedf5aed36967a4d5b40996bc5c66e80f8 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 job-iteration (1.5.1) sha256=1428ad5b308adbaae8776c16b7792a846eb1ad7f4ab3c6e0f9668dd2ab1179e5 - json (2.7.2) sha256=1898b5cbc81cd36c0fd4d0b7ad2682c39fb07c5ff682fc6265f678f550d4982c - json-jwt (1.16.6) sha256=ab451f9cd8743cecc4137f4170806046c1d8a6d4ee6e8570e0b5c958409b266c + json (2.8.2) sha256=dd4fa6c9c81daecf72b86ea36e56ed8955fdbb4d4dc379c93d313a59344486cf + json-jwt (1.16.7) sha256=ccabff4c6d1a14276b23178e8bebe513ef236399b72a0b886d7ed94800d172a5 jwt (2.7.1) sha256=07357cd2f180739b2f8184eda969e252d850ac996ed0a23f616e8ff0a90ae19b kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff language_server-protocol (3.17.0.3) sha256=3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f - launchdarkly-server-sdk (8.6.0) sha256=c18c62d08f90b795105e98f07a24997a5628126623fadbd3b80c0d23b9409b75 + launchdarkly-server-sdk (8.8.2) sha256=7a65646751b7e37389d8b0a2717eef1584b0d5829ba2c3efbc09d0e80047ac49 launchy (3.0.1) sha256=b7fa60bda0197cf57614e271a250a8ca1f6a34ab889a3c73f67ec5d57c8a7f2c ld-eventsource (2.2.2) sha256=5ea087a6f06bbd8e325d2c1aaead50f37f13d025b952985739e9380a78a96beb letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 - libdatadog (9.0.0.1.0) sha256=2a24dd3ee462e59de04f098687ed3d98823042aebfdd3f1b75fd92374b926f07 - libddwaf (1.14.0.0.0) sha256=b91ea9675f7d79d1cd10dd6513e3706760ac442cb8902164fbcef79b7082a8fd + libdatadog (14.1.0.1.0) sha256=6b5c7d03a6f67e148425d98cc5f43a6b1a85da32862e7689cb08db94cabc151e + libdatadog (14.1.0.1.0-aarch64-linux) sha256=cdde91ca08c8cface9420f00a217fd0140337ec24c962342dffc9a1b3fdf5691 + libdatadog (14.1.0.1.0-x86_64-linux) sha256=136a79e3abc24b07376a1e2a8be9ddc5212b002bcad546af866385b4d986b28e + libddwaf (1.15.0.0.0) sha256=5a0b6bb1bf9208cc3c8df4393e0f19ae1faf9846e8e8dbc2e10ecd5cb3c756f0 + libddwaf (1.15.0.0.0-aarch64-linux) sha256=1630f38b57bc1a20bc1102bfbfc328fd7c522cf5828aebb02dbb45460cb834e7 + libddwaf (1.15.0.0.0-arm64-darwin) sha256=714d497c080a385ad1a735b9163d58ea67a861df8d61e8f5669dbfdf8df744ea + libddwaf (1.15.0.0.0-x86_64-darwin) sha256=76a9361fb90ecfefc7e95871612851ec56603c6cea88538d102b790101e86250 + libddwaf (1.15.0.0.0-x86_64-linux) sha256=331c2e54679a6ecb4fa9d4940e29c6c732efb3f407cb83dfc0671daca99d568e listen (3.9.0) sha256=db9e4424e0e5834480385197c139cb6b0ae0ef28cc13310cfd1ca78377d59c67 llhttp-ffi (0.5.0) sha256=496f40ad44bcbf99de02da1f26b1ad64e6593cd487b931508a86228e2a3af0fa - logger (1.6.0) sha256=0ab7c120262dd8de2a18cb8d377f1f318cbe98535160a508af9e7710ff43ef3e - loofah (2.22.0) sha256=10d76e070c86b12fec74b6a9515fd1940f4459198b991342d0a7897d86c372fe - lookbook (2.3.2) sha256=abcdefeb13d9150ab30ab8022a2f459db85cbd02e90a0e8e066b32f9048e160a + local_time (3.0.2) sha256=cb8abb2d56726ae285d00b0bd7f4c34bafc38c0c1cbc70ddc28f3a5661168d9d + logger (1.6.1) sha256=3ad9587ed3940bf7897ea64a673971415523f4f7d6b22c5e3af5219705669653 + loofah (2.23.1) sha256=d0a07422cb3b69272e124afa914ef6d517e30d5496b7f1c1fc5b95481f13f75e + lookbook (2.3.4) sha256=16484c9eb514ac0c23c4b59cfd5a52697141d35056e3a9c2a22b314c1b887605 mail (2.8.1) sha256=ec3b9fadcf2b3755c78785cb17bc9a0ca9ee9857108a64b6f5cfc9c0b5bfc9ad - maintenance_tasks (2.7.1) sha256=2f1f42239a11f5b30ef34e821f1beb42010d08e81634d8945884aba8bdb982db + maintenance_tasks (2.8.0) sha256=7ee8aa37ab39c6c3a5f4637878c1a343cc296596742248112458b922968d4a16 marcel (1.0.4) sha256=0d5649feb64b8f19f3d3468b96c680bae9746335d02194270287868a661516a4 matrix (0.4.2) sha256=71083ccbd67a14a43bfa78d3e4dc0f4b503b9cc18e5b4b1d686dc0f9ef7c4cc0 - memory_profiler (1.0.2) sha256=0e7c5c2a1a7bea5b5a05b9df25b2d628afa0db37b9344ba42b42eb8a604762df - meta-tags (2.21.0) sha256=cfaa2ccd32b2700c3538c9eae647191295a06f47f0d8558e685a6a7158171cfb + memory_profiler (1.1.0) sha256=79a17df7980a140c83c469785905409d3027ca614c42c086089d128b805aa8f8 + meta-tags (2.22.1) sha256=e5ae1febbd320d396c7226d7edb868e5d63466c14b9c8b06622a1a74e6dce354 method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 mini_histogram (0.3.1) sha256=6a114b504e4618b0e076cc672996036870f7cc6f16b8e5c25c0c637726d2dd94 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef - mini_portile2 (2.8.7) sha256=13eef5ab459bbfd33d61e539564ec25a9c2cf593b0a5ea6d4d7ef8c19b162ee0 - minitest (5.24.1) sha256=31ec31ac9088d9e21fcc5a5487912234de83966f24368241b2bef03d7012464a + mini_portile2 (2.8.8) sha256=8e47136cdac04ce81750bb6c09733b37895bf06962554e4b4056d78168d70a75 + minitest (5.25.1) sha256=3db6795a80634def1cf86fda79d2d83b59b25ce5e186fa675f73c565589d2ad8 minitest-gcstats (1.3.1) sha256=cb25490f93aac02e3a5ff307e560d41afcdcafa7952c1c32efdeb9886b1f4711 minitest-reporters (1.7.1) sha256=5060413a0c95b8c32fe73e0606f3631c173a884d7900e50013e15094eb50562c - minitest-retry (0.2.2) sha256=ea39f8abc3d67a8145ead04ff3828eb45169655c9e6078f182c0271516c03fb0 - mocha (2.4.0) sha256=dae4d68652b7b70c711e57cfcc1778bed6d740933edb692ac83741346bc7f7af - msgpack (1.7.2) sha256=59ab62fd8a4d0dfbde45009f87eb6f158ab2628a7c48886b0256f175166baaa8 + minitest-retry (0.2.3) sha256=7b7f4896efb9b931a1acb442a40e5273c441f44946cf4c6a8eb8895838e7bf29 + mocha (2.5.0) sha256=7852595064e8ef4c6a3f6d8a5a5ab8c705168f913bb17929ab1c35f4dd4c7717 + msgpack (1.7.5) sha256=ffb04979f51e6406823c03abe50e1da2c825c55a37dee138518cdd09d9d3aea8 multi_json (1.15.0) sha256=1fd04138b6e4a90017e8d1b804c039031399866ff3fbabb7822aea367c78615d multi_xml (0.7.1) sha256=4fce100c68af588ff91b8ba90a0bb3f0466f06c909f21a32f4962059140ba61b + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 mutex_m (0.2.0) sha256=b6ef0c6c842ede846f2ec0ade9e266b1a9dac0bc151682b04835e8ebd54840d5 - net-http (0.4.1) sha256=a96efc5ea18bcb9715e24dda4159d10f67ff0345c8a980d04630028055b2c282 - net-imap (0.4.14) sha256=a2184b3f09a4f7ca27998d113fd6df8cd0dd56b91e5bf0d6387b8350bb438065 + net-http (0.5.0) sha256=ed7f88205afe03bf53142a4b81ded91f2c01522dcf03089cb6ad4acb476ce1da + net-imap (0.5.1) sha256=c0ceb85d8459f7081d5ed1ac86159f8e80d25e704eb52dbf0d9f703b7bc838d7 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 net-smtp (0.5.0) sha256=5fc0415e6ea1cc0b3dfea7270438ec22b278ca8d524986a3ae4e5ae8d087b42a - nio4r (2.7.0) sha256=9586a685eca8246d6406e712a525e705d15bb88f709d78fc3f141e864df97276 - nokogiri (1.16.6) sha256=935fe4dd67d4377f4a05002acb1ffbadbcae265ea8e7869fc40e3a8121f3e1ef + nio4r (2.7.3) sha256=54b94cdd4b8f9dc39aaad5f699e97afae13efb44f2b180a6e724df76105ff604 + nokogiri (1.16.7) sha256=f819cbfdfb0a7b19c9c52c6f2ca63df0e58a6125f4f139707b586b9511d7fe95 + nokogiri (1.16.7-aarch64-linux) sha256=78778d35f165b59513be31c0fe232c63a82cf97626ffba695b5f822e5da1d74b + nokogiri (1.16.7-arm64-darwin) sha256=276dcea1b988a5b22b5acc1ba901d24b8e908c40b71dccd5d54a2ae279480dad + nokogiri (1.16.7-x86_64-darwin) sha256=630732b80fc572690eab50c73a1f18988f3ac401ed0b67ca9956ba2b1e2c3faa + nokogiri (1.16.7-x86_64-linux) sha256=9e1e428641d5942af877c60b418c71163560e9feb4a5c4015f3230a8b86a40f6 oauth2 (2.0.9) sha256=b21f9defcf52dc1610e0dfab4c868342173dcd707fd15c777d9f4f04e153f7fb observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac - octokit (9.1.0) sha256=7849a659d2722c629181f48d1d7e567c9539f1a85c9676144dbdbfc6ce288253 + octokit (9.2.0) sha256=4fa47ff35ce654127edf2c836ab9269bcc8829f5542dc1e86871f697ce7f4316 omniauth (2.1.2) sha256=def03277298b8f8a5d3ff16cdb2eb5edb9bffed60ee7dda24cc0c89b3ae6a0ce omniauth-github (2.0.1) sha256=8ff8e70ac6d6db9d52485eef52cfa894938c941496e66b52b5e2773ade3ccad4 omniauth-oauth2 (1.8.0) sha256=b2f8e9559cc7e2d4efba57607691d6d2b634b879fc5b5b6ccfefa3da85089e78 omniauth-rails_csrf_protection (1.0.2) sha256=1170fd672aff092b9b7ebebc1453559f073ed001e3ce62a1df616e32f8dc5fe0 - openid_connect (2.3.0) sha256=0dbb9cefeb11e0a65e706349266355bbbb060382ae138fc9e199ab1aa622744c - opensearch-ruby (3.3.0) sha256=f490c0150bdb2a6444f1d4ec128e9947aadb1c74f0315a29c574c0d31acfeced + openid_connect (2.3.1) sha256=5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182 + opensearch-ruby (3.4.0) sha256=0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d openssl (3.2.0) sha256=3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14 openssl-signature_algorithm (1.3.0) sha256=a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80 optimist (3.1.0) sha256=81886f53ee8919f330aa30076d320d88eef9bc85aae2275376b4afb007c69260 - pagy (8.4.0) sha256=21dd68453d24e752a1bc81aef5b6f1346ff95e1c2e5b53448355f7856f3ef901 - parallel (1.25.1) sha256=12e089b9aa36ea2343f6e93f18cfcebd031798253db8260590d26a7f70b1ab90 - parser (3.3.3.0) sha256=a2e23c90918d9b7e866b18dca2b6835f227769dd2fa8e59c5841f3389cf53eeb - pg (1.5.6) sha256=4bc3ad2438825eea68457373555e3fd4ea1a82027b8a6be98ef57c0d57292b1c + ostruct (0.6.0) sha256=3b1736c99f4d985de36bde1155be5e22aaf6e564b30ff9bd481e2ef7c2d9ba85 + pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2 + parallel (1.26.3) sha256=d86babb7a2b814be9f4b81587bf0b6ce2da7d45969fab24d8ae4bf2bb4d4c7ef + parser (3.3.5.0) sha256=f30ebb71b7830c2e7cdc4b2b0e0ec2234900e3fca3fe2fba47f78be759181ab3 + pg (1.5.9) sha256=761efbdf73b66516f0c26fcbe6515dc7500c3f0aa1a1b853feae245433c64fdc pg_query (5.1.0) sha256=b7f7f47c864f08ccbed46a8244906fb6ee77ee344fd27250717963928c93145d - pghero (3.5.0) sha256=7b459d383673e358017d0dd210c11b6a82bbfb340c73236ba0e50bb6c0351e6a - phlex (1.10.2) sha256=49dca7df081258f937be5e4ee0a81b11743f2b4fea25ac7537912b9c9344b1e6 - phlex-rails (1.2.1) sha256=1d80709c02114cda869951d22bfca189b5f208d1eb89f2e6fafbe3c0240a822f - pp (0.5.0) sha256=f8f40bc2ba9e1ab351b9458151da3a89f46034f7f599a8e0a06abb9b9f4411dd + pghero (3.6.1) sha256=e6d4f6ec3979d4828dafcd1eaa4214e70279fe2502b9fe5bd632d8333aa79cd4 + phlex (1.11.0) sha256=979548e79a205c981612f1ab613addc8fa128c8092694d02f41aad4cea905e73 + phlex-rails (1.2.2) sha256=a20218449e71bc9fa5a71b672fbede8a654c6b32a58f1c4ea83ddc1682307a4c + pp (0.6.1) sha256=16d45bd9972616e81090ba08e119161131eb5b6348e85e43c4efffc8c5fe9fea prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 + propshaft (1.1.0) sha256=d389361faf66aeb17e8d204828962c1e506edd14a1a17adb3fa475435c070f6b prosopite (1.4.2) sha256=b2e422e2d9dbf3ce20130ded3252fe14adb3833555eacd451f5015d8c9177e79 + protobug (0.1.0) sha256=5bf1356cedf99dcf311890743b78f5e602f62ca703e574764337f1996b746bf2 + protobug_googleapis_field_behavior_protos (0.1.0) sha256=db48ef6a5913b2355b4a6931ab400a9e3e995fb48499977a3ad0be6365f9e265 + protobug_sigstore_protos (0.1.0) sha256=4ad1eebaf6454131b6f432dda50ad0e513773613474b92470847614a5acacce1 + protobug_well_known_protos (0.1.0) sha256=356757f562453bb34a28f12e8e9fa357346cca35a6807a549837c3fe256bb5b3 pry (0.14.1) sha256=99b6df0665875dd5a39d85e0150aa5a12e2bb4fef401b6c4f64d32ee502f8454 pry-byebug (3.10.1) sha256=c8f975c32255bfdb29e151f5532130be64ff3d0042dc858d0907e849125581f8 - psych (5.1.2) sha256=337322f58fc2bf24827d2b9bd5ab595f6a72971867d151bb39980060ea40a368 - public_suffix (6.0.0) sha256=d2c8e12e076813f8fca0307205c088c6903adc70b92c65ab22479dfa9bc0a89e - puma (6.4.2) sha256=3eb41999d00733280be3faeb00d610331b6965a537a8cd3d905f316c38575b44 - pundit (2.3.2) sha256=7ca09a5801ebaedad1966f7eb0b1c52ecb8c94b3b6ab70122cb22856ac187fa3 + psych (5.2.0) sha256=6603fe756bcaf14daa25bc17625f36c90931dcf70452ac1e8da19760dc310573 + public_suffix (6.0.1) sha256=61d44e1cab5cbbbe5b31068481cf16976dd0dc1b6b07bd95617ef8c5e3e00c6f + puma (6.4.3) sha256=24a4645c006811d83f2480057d1f54a96e7627b6b90e1c99b260b9dc630eb43e + pundit (2.4.0) sha256=43e6d27a9df082c04f0020999ce4dcf6742ecc5775d102ef2bfe9df041417572 pwned (2.3.0) sha256=63f5a9576f109203684e9dd053f815649fd5bc0a0348b7190568272641b22353 raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 - racc (1.8.0) sha256=09349a65c37c4fe710a435f25c9f1652e39f29ad6b1fa08d4a8d30c0553d3a08 - rack (3.1.6) sha256=08cd573725c9c223bb0466d483153cae9bb3606d17760328c814902e6bf83c32 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.1.8) sha256=d3fbcbca43dc2b43c9c6d7dfbac01667ae58643c42cea10013d0da970218a1b1 rack-attack (6.7.0) sha256=3ca47e8f66cd33b2c96af53ea4754525cd928ed3fa8da10ee6dad0277791d77c rack-oauth2 (2.2.1) sha256=c73aa87c508043e2258f02b4fb110cacba9b37d2ccf884e22487d014a120d1a5 rack-protection (4.0.0) sha256=d0db6185caa46a8c0d134c2c6b4803f4f392a67b2984da311409cb505f67bbf6 + rack-sanitizer (2.0.3) sha256=3e492e1b56b0005fb1b56d77dff996821059aad4321634883ae3c42c865f9af0 rack-session (2.0.0) sha256=db04b2063e180369192a9046b4559af311990af38c6a93d4c600cee4eb6d4e81 rack-test (2.1.0) sha256=0c61fc61904049d691922ea4bb99e28004ed3f43aa5cfd495024cc345f125dfb - rack-utf8_sanitizer (1.9.1) sha256=6414b70172f5678e23044abf1d00f6a32e62a335507c9548bc5caf9e3bff6da0 - rackup (2.1.0) sha256=6ecb884a581990332e45ee17bdfdc14ccbee46c2f710ae1566019907869a6c4d - rails (7.1.3.4) sha256=3a7fca9df74ee641dc1e89b8302ac6d03f22883de771e786a0e9f3094e5aa6ad + rackup (2.2.1) sha256=f737191fd5c5b348b7f0a4412a3b86383f88c43e13b8217b63d4c8d90b9e798d + rails (7.2.1.1) sha256=79dbdb6feebf78c1172b643a273a4b1c6c8a18e101a4bb1c838be611a3cdcae7 rails-controller-testing (1.0.5) sha256=741448db59366073e86fc965ba403f881c636b79a2c39a48d0486f2607182e94 rails-dom-testing (2.2.0) sha256=e515712e48df1f687a1d7c380fd7b07b8558faa26464474da64183a7426fa93b rails-erd (1.7.2) sha256=0b17d0fba25d319d8da8af7a3e5e2149d02d6187cc7351e8be43423f07c48bcd rails-html-sanitizer (1.6.0) sha256=86e9f19d2e6748890dcc2633c8945ca45baa08a1df9d8c215ce17b3b0afaa4de - rails-i18n (7.0.9) sha256=c184db80a7c7bf21c14e0e400fe9e27c4c20312f019aaff5b364a82858dc1369 - rails_semantic_logger (4.16.0) sha256=766f6cd482f3c811083b24777ea8ac04bb7f44c4fdb189cf1af3ae2b9b844cf6 - railties (7.1.3.4) sha256=6c6049f3a788669d94f95c7bf6378204ae94098567cc25237e3c73dac4a21afc + rails-i18n (7.0.10) sha256=efae16e0ac28c0f42e98555c8db1327d69ab02058c8b535e0933cb106dd931ca + rails_semantic_logger (4.17.0) sha256=cc10cca01491736596cd5ab40b52b3bbf98338d9d1f5a7916b2be10231615047 + railties (7.2.1.1) sha256=16ac9ddd079b8e8c4c3386234512c301ab7a302bce3cbe0560ac44cec7d88453 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.2.1) sha256=46cb38dae65d7d74b6020a4ac9d48afed8eb8149c040eccf0523bec91907059d + ransack (4.2.1) sha256=e0f688c6ef35abe0bad3cf5afc1b050f36a819bdd56eb454c955c63cdd1bef40 rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.10.1) sha256=050062d4f31d307cca52c3f6a7f4b946df8de25fc4bd373e1a5142e41034a7ca rbtrace (0.5.1) sha256=e8cba64d462bfb8ba102d7be2ecaacc789247d52ac587d8003549d909cb9c5dc rdoc (6.7.0) sha256=b17d5f0f57b0853d7b880d4360a32c7caf8dbb81f8503a36426df809e617f379 redcarpet (3.6.0) sha256=8ad1889c0355ff4c47174af14edd06d62f45a326da1da6e8a121d59bdcd2e9e9 regexp_parser (2.9.2) sha256=5a27e767ad634f8a4b544520d5cd28a0db7aa1198a5d7c9d7e11d7b3d9066446 - reline (0.5.9) sha256=5d2dd7ed0fd078e79a05e4eaa47dc91b8dacec7358e9e1dd6d9c4636cff7d378 - rexml (3.3.1) sha256=34af9fb38eff6c451abd187c53fded98378aa91766d4c62fbbce10e40ed7c325 + reline (0.5.11) sha256=868d5f4dbfd9caafa70182f7f6fa258b70baee4e565d7cd9e70b4d5b11a7cb65 + rexml (3.3.9) sha256=d71875b85299f341edf47d44df0212e7658cbdf35aeb69cefdb63f57af3137c9 roadie (5.2.1) sha256=e4a4f61ce792bd91b228b6844b4bad6b160cdc1b8df86c81a8b983082a5001d6 - roadie-rails (3.2.0) sha256=90a534857fcfe9fdbe4f9ebfdbc47e5d33462c4f36f478fc80ba6a7b6257433f + roadie-rails (3.3.0) sha256=4080f67a635962fb3df77ed42b2992ff41ee6502b60b5f4609a9fb9eb06c0a5e rotp (6.3.0) sha256=75d40087e65ed0d8022c33055a6306c1c400d1c12261932533b5d6cbcd868854 - rouge (4.3.0) sha256=9ee3d9ec53338e78c03fff0cbcd08881d80d69152349b046761e48ccf2de581c + rouge (4.4.0) sha256=7a6d6d951e3202e4ce3926838625fa6edeb35680e6d1e3817f53c14212220b64 rqrcode (2.2.0) sha256=23eea88bb44c7ee6d6cab9354d08c287f7ebcdc6112e1fe7bcc2d010d1ffefc1 rqrcode_core (1.2.0) sha256=cf4989dc82d24e2877984738c4ee569308625fed2a810960f1b02d68d0308d1a - rubocop (1.64.1) sha256=3145bf1863771e400a1c041060e751e5ff0edd9ceb99d01df36db1902f611f3b - rubocop-ast (1.31.3) sha256=1b07d618d8776993ec6053a706d1c09f0bf15139fd69415924656cbff07e7818 + rspec (3.13.0) sha256=d490914ac1d5a5a64a0e1400c1d54ddd2a501324d703b8cfe83f458337bab993 + rspec-core (3.13.0) sha256=557792b4e88da883d580342b263d9652b6a10a12d5bda9ef967b01a48f15454c + rspec-expectations (3.13.1) sha256=814cf8dadc797b00be55a84d7bc390c082735e5c914e62cbe8d0e19774b74200 + rspec-mocks (3.13.1) sha256=087189899c337937bcf1d66a50dc3fc999ac88335bbeba4d385c2a38c87d7b38 + rspec-support (3.13.1) sha256=48877d4f15b772b7538f3693c22225f2eda490ba65a0515c4e7cf6f2f17de70f + rubocop (1.66.1) sha256=0679c263b1164fd003b8590ae83b3e9e9bf72282d411755f227f1d6268ee5ee7 + rubocop-ast (1.32.3) sha256=40201e861c73a3c2d59428c7627828ef81fb2f8a306bc4a1c1801452afe3fe0f rubocop-capybara (2.21.0) sha256=5d264efdd8b6c7081a3d4889decf1451a1cfaaec204d81534e236bc825b280ab rubocop-factory_bot (2.26.1) sha256=8de13cd4edcee5ca800f255188167ecef8dbfc3d1fae9f15734e9d2e755392aa - rubocop-minitest (0.35.0) sha256=8411f3a858a445f1930f41876eabf4a0511aa258b339ec19010fdca159272e00 - rubocop-performance (1.21.1) sha256=5cf20002a544275ad6aa99abca4b945d2a2ed71be925c38fe83700360ed8734e - rubocop-rails (2.25.1) sha256=4988933ee02fdb213d22d0f61dc57d6c582317b43867d5106ea1c7a628aae6a5 + rubocop-minitest (0.36.0) sha256=1d15850849c685ff4b6d64dd801ec2d13eb2fe56b6f7ce9aab93d1b0508e7b9f + rubocop-performance (1.22.1) sha256=9ed9737af1ee90655654b483e0eac4e64702139e85d33335bf744b57a309a679 + rubocop-rails (2.26.2) sha256=f5561a09d6afd2f54316f3f0f6057338ca55b6c24a25ba6a938d3ed0fded84ad ruby-graphviz (1.2.5) sha256=1c2bb44e3f6da9e2b829f5e7fd8d75a521485fb6b4d1fc66ff0f93f906121504 ruby-magic (0.6.0) sha256=7b2138877b7d23aff812c95564eba6473b74b815ef85beb0eb792e729a2b6101 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 - ruby-statistics (3.0.2) sha256=fb53e7a9f9681dac144c02539d3535fb2e8fae626f78b907219b0586ff53ec20 + ruby-statistics (4.0.1) sha256=aede68e6261b979431b31ba39aa661844c18f97a89966768cb60edf2a56b6782 ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubyzip (2.3.2) sha256=3f57e3935dc2255c414484fbf8d673b4909d8a6a57007ed754dde39342d2373f safety_net_attestation (0.4.0) sha256=96be2d74e7ed26453a51894913449bea0e072f44490021545ac2d1c38b0718ce - sass-embedded (1.72.0) sha256=05d3e53092022a4b4eef5b76b9597c7f2af4e4efb06812e1a60e3601189a3d2e - sassc-embedded (1.70.1) sha256=a95172c9c6725dfc412c702a0e705fb8a5bcb3aac2a32586b18e5432987103d3 sawyer (0.9.2) sha256=fa3a72d62a4525517b18857ddb78926aab3424de0129be6772a8e2ba240e7aca - searchkick (5.3.1) sha256=dc1181543f6a68354e380651f235fa7f3df6a09e4cd67fc284dc293fa9860f57 - selenium-webdriver (4.22.0) sha256=644370abcdcf1f14b2581c125d39014a1933b20e355c74b0dc5d0ca877a45d66 + searchkick (5.4.0) sha256=75d7256d3ec2af2dc11c2ba8160c86d80451f3f86447aae2ace1f79553de0bf3 + securerandom (0.3.2) sha256=e8b2ffa651dfbbb26eb4bfb8ddcfff94221a93e3f118f39e0f7f94c14fea9dc0 + selenium-webdriver (4.26.0) sha256=bb0426ffe50e5940a6a5ed2978b4dfb1cb29e0e1c4d0a420d6aabf0f6c8e0690 semantic (1.6.1) sha256=3cdbb48f59198ebb782a3fdfb87b559e0822a311610db153bae22777a7d0c163 - semantic_logger (4.15.0) sha256=ec4f56122b5d2e2117d148b86c69fb62c1194a2b01a271be04ba8678a85f81ff + semantic_logger (4.16.0) sha256=ffba0bd0e008ceaf6be26da588f610a61208b9a9f55676b32729e962904023d9 shoryuken (6.2.1) sha256=95ddc0a717624a54e799d25a0a05100cb5a0c3728a96211935c214faaf16b3b6 shoulda-context (3.0.0.rc1) sha256=6e0d9d52ab798c13bc2b490c8537d4bf30cfd318a1ea839c39a66d1d293c6a1a - shoulda-matchers (6.2.0) sha256=a702c059c5f3bbda2295827231a28654e33063d7c4d409662980ec630207d8dd + shoulda-matchers (6.4.0) sha256=9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0 + sigstore (0.1.1) sha256=0c2c3c5d175b204252eeb1507bfb79e330009188d160525d2871b5272f958897 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-cobertura (2.1.0) sha256=2c6532e34df2e38a379d72cef9a05c3b16c64ce90566beebc6887801c4ad3f02 simplecov-html (0.12.3) sha256=4b1aad33259ffba8b29c6876c12db70e5750cb9df829486e4c6e5da4fa0aa07b simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 smart_properties (1.17.0) sha256=f9323f8122e932341756ddec8e0ac9ec6e238408a7661508be99439ca6d6384b snaky_hash (2.0.1) sha256=1ac87ec157fcfe7a460e821e0cd48ae1e6f5e3e082ab520f03f31a9259dbdc31 - sprockets (4.2.1) sha256=951b13dd2f2fcae840a7184722689a803e0ff9d2702d902bd844b196da773f97 - sprockets-rails (3.5.1) sha256=c44626cb3887a1a8b572ca258685db33b4ebd041aa73428a716eac444ee5ef48 - statsd-instrument (3.8.0) sha256=122e294845d9a05a74fa53a859010424b455b44f1605abac3108fbbabb2aa7cd - stimulus-rails (1.3.3) sha256=4d1f9ab1d64e605f4c9cdd4cc530a9538b510606d32d02249d106256845c562c - stringio (3.1.1) sha256=53456e14175c594e0e8eb2206a1be33f3974d4fe21c131e628908b05c8c2ae1e - strong_migrations (2.0.0) sha256=88750f294403e18ec674eda6901f2fc195f553ed6a7928c52e8a3f5b57ff501d - strscan (3.1.0) sha256=01b8a81d214fbf7b5308c6fb51b5972bbfc4a6aa1f166fd3618ba97e0fcd5555 + statsd-instrument (3.9.7) sha256=98feeeb39f95f2805c2070b46ae3e72e30679758301ba2f78d075e5b49edf6a5 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.1.2) sha256=204f1828f85cdb39d57cac4abc6dc44b04505a223f131587f2e20ae3729ba131 + strong_migrations (2.1.0) sha256=d48015334c4f9276a2e82e0ed6a2f610424afdbccae64a761b8c900aa10092f9 swd (2.0.3) sha256=4cdbe2a4246c19f093fce22e967ec3ebdd4657d37673672e621bf0c7eb770655 - tailwindcss-rails (2.6.1) sha256=60e66e243761402f9ce834132f59dd6c08b6fea5987e321bd9886dd0b0ce04ac - terser (1.2.3) sha256=c03111b9b01a7e70cd456b5d9aedf0a56fb99314a19311c4278c01ccebe3da9c - thor (1.3.1) sha256=fa7e3471d4f6a27138e3d9c9b0d4daac9c3d7383927667ae83e9ab42ae7401ef - tilt (2.3.0) sha256=82dd903d61213c63679d28e404ee8e10d1b0fdf5270f1ad0898ec314cc3e745c - timeout (0.4.1) sha256=6f1f4edd4bca28cffa59501733a94215407c6960bd2107331f0280d4abdebb9a + tailwindcss-rails (3.0.0) sha256=ee9e5956411968fa445d2bb514c8fb70b239ca2e6f2d5bae06b161e2bee19446 + tailwindcss-ruby (3.4.14) sha256=80e0eb4f369ca48c00b688f88730c5fefe2ea9821f16f03a86dd2a6bf56dda3a + tailwindcss-ruby (3.4.14-aarch64-linux) sha256=e4d15a6c8d06cf0c1f45e2582836c92be718e9bc9025067af282816b6336575f + tailwindcss-ruby (3.4.14-arm64-darwin) sha256=439ea278878d42b40f775db3783b8fd7d25fa969fd8c11f42436ba064752ae3a + tailwindcss-ruby (3.4.14-x86_64-darwin) sha256=add1f6c63cfb7851557323ef7bc7ed49d927ba610b4b363d1066389232ade3ef + tailwindcss-ruby (3.4.14-x86_64-linux) sha256=c23dec24cb855d748750e8f4bca1b048a1e617f381e81336c8ab65ede7e84d5c + thor (1.3.2) sha256=eef0293b9e24158ccad7ab383ae83534b7ad4ed99c09f96f1a6b036550abbeda + timeout (0.4.2) sha256=8aca2d5ff98eb2f7a501c03f8c3622065932cc58bc58f725cd50a09e63b4cc19 timescaledb (0.3.0) sha256=9ce2b39417d30544054cb609fbd84e18e304c7b7952a793846b8f4489551a28f toxiproxy (2.0.2) sha256=2e3b53604fb921d40da3db8f78a52b3133fcae33e93d440725335b15974e440a tpm-key_attestation (0.12.0) sha256=e133d80cf24fef0e7a7dfad00fd6aeff01fc79875fbfc66cd8537bbd622b1e6d - turbo-rails (1.5.0) sha256=b426cc762fb0940277729b3f1751a9f0bd269f5613c1d62ac73e5f0be7c7a83e - turbo_power (0.5.0) sha256=869726c3a4308b038d6244f7a80eb1331bdd3171fa320dddcff71899fcae24fb + turbo-rails (2.0.11) sha256=fc47674736372780abd2a4dc0d84bef242f5ca156a457cd7fa6308291e397fcf + turbo_power (0.6.2) sha256=c9080d0d1bb79deed67bee2a7654dd38f9c903b57ad52b98d19d000958fde2cc tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - unicode-display_width (2.5.0) sha256=7e7681dcade1add70cb9fda20dd77f300b8587c81ebbd165d14fd93144ff0ab4 + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a unpwn (1.0.0) sha256=6239d17d46a882b3719b24fb79c78a34caff89d57ab0f5e546be5b5c882bc7d3 - uri (0.13.0) sha256=26553c2a9399762e1e8bebd4444b4361c4b21298cf1c864b22eeabc9c4998f24 + uri (1.0.2) sha256=b303504ceb7e5905771fa7fa14b649652fa949df18b5880d69cfb12494791e27 user_agent_parser (2.18.0) sha256=aa943b91da8906cace7d3fe16b450c9d77b68f571485c11e577af97aecb25584 + useragent (0.16.10) sha256=1794380d9ea5c087d687bbfe14752f81839293f238c1132ef05c9344f09e65bb validate_url (1.0.15) sha256=72fe164c0713d63a9970bd6700bea948babbfbdcec392f2342b6704042f57451 validates_formatting_of (0.9.0) sha256=139590a4b87596dbfb04d93e897bd2e6d30fb849d04fab0343e71ed2ca856e7e version_gem (1.1.1) sha256=3c2da6ded29045ddcc0387e152dc634e1f0c490b7128dce0697ccc1cf0915b6c - view_component (3.12.1) sha256=f2ce2ad2945389f4bbd4ff77465605e9019041e5c804d16d093791be2542b18b + view_component (3.14.0) sha256=96816de1c40d276d9fac49316ee4d196de90b1ce6eb39373b887c639749e630c webauthn (3.1.0) sha256=e545fcf17d8a6b821161a37c1c4bc8c3d2ead0ff6ff3b098f57f417e731790b7 webfinger (2.1.3) sha256=567a52bde77fb38ca6b67e55db755f988766ec4651c1d24916a65aa70540695c - webmock (3.23.1) sha256=0fa738c0767d1c4ec8cc57f6b21998f0c238c8a5b32450df1c847f2767140d95 - webrick (1.8.1) sha256=19411ec6912911fd3df13559110127ea2badd0c035f7762873f58afc803e158f - websocket (1.2.10) sha256=2cc1a4a79b6e63637b326b4273e46adcddf7871caa5dc5711f2ca4061a629fa8 + webmock (3.24.0) sha256=be01357f6fc773606337ca79f3ba332b7d52cbe5c27587671abc0572dbec7122 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.7.6) sha256=f69400be7bc197879726ad8e6f5869a61823147372fd8928836a53c2c741d0db websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e - yard (0.9.36) sha256=5505736c1b00c926f71053a606ab75f02070c5960d0778b901fe9d8b0a470be4 - zeitwerk (2.6.16) sha256=17df48537f09a937804f79bd9a92817da3b199e268634972d0e98a21ca588e21 + yard (0.9.37) sha256=a6e910399e78e613f80ba9add9ba7c394b1a935f083cccbef82903a3d2a26992 + zeitwerk (2.7.1) sha256=0945986050e4907140895378e74df1fe882a2271ed087cc6c6d6b00d415a2756 + zlib (3.1.1) sha256=f61bb03139bbe256c36ba99ef9fece1fb223e9034ed9e5fa3ddb1588d99abc71 RUBY VERSION - ruby 3.3.3p89 + ruby 3.3.5p100 BUNDLED WITH - 2.5.14 + 2.5.21 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js deleted file mode 100644 index 2b38b4b8101..00000000000 --- a/app/assets/config/manifest.js +++ /dev/null @@ -1,6 +0,0 @@ -//= link application.css -//= link_tree ../../../vendor/assets/images -//= link_tree ../builds -//= link_tree ../../javascript .js -//= link_tree ../../javascript/src .js -//= link_tree ../../../vendor/javascript .js diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg new file mode 100644 index 00000000000..f261c13645c --- /dev/null +++ b/app/assets/images/icons.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 60707cefb77..dceb43dbed2 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,8 +1,26 @@ -/* - * This is a manifest file that'll automatically include all the stylesheets available in this directory - * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at - * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require_self - *= require github_buttons - *= require_tree . -*/ +@import url("base.css"); +@import url("layout.css"); +@import url("suggest-list.css"); +@import url("type.css"); +@import url("github_buttons.css"); + +@import url("modules/badge.css"); +@import url("modules/button.css"); +@import url("modules/dependencies.css"); +@import url("modules/error.css"); +@import url("modules/footer.css"); +@import url("modules/form.css"); +@import url("modules/gem.css"); +@import url("modules/gems.css"); +@import url("modules/header.css"); +@import url("modules/home.css"); +@import url("modules/mfa.css"); +@import url("modules/nav/nav--paginated.css"); +@import url("modules/nav/nav--v.css"); +@import url("modules/news.css"); +@import url("modules/org.css"); +@import url("modules/owners.css"); +@import url("modules/search.css"); +@import url("modules/shared.css"); +@import url("modules/stats.css"); +@import url("modules/status-icon.css"); diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 3bbac920f0d..2e81a977bc0 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -1,3 +1,4 @@ -/* @tailwind base; */ /* base disabled because the resets break the current site */ +@config "../../../config/tailwind.config.js"; +@tailwind base; @tailwind components; @tailwind utilities; diff --git a/app/assets/stylesheets/hammy.css b/app/assets/stylesheets/hammy.css new file mode 100644 index 00000000000..d783d05bab0 --- /dev/null +++ b/app/assets/stylesheets/hammy.css @@ -0,0 +1,22 @@ +/* + * This is a manifest file that'll automatically include all the stylesheets available in this directory + * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at + * the top of the compiled file, but it's generally better to create a new file per style scope. + *= require_self +*/ + +/* Default browser style adds a max width that is hard to override with tailwind directly */ +dialog:modal { + max-width: 100vw; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.no-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} diff --git a/app/assets/stylesheets/modules/gem.css b/app/assets/stylesheets/modules/gem.css index 22c4eba7734..71a6dd657e0 100644 --- a/app/assets/stylesheets/modules/gem.css +++ b/app/assets/stylesheets/modules/gem.css @@ -96,6 +96,14 @@ font-size: 18px; color: #141c22; } +.gem__rubygem-version-age { + margin-top: 10px; + margin-bottom: 30px; + display: block; + font-weight: 800; + font-size: 14px; + color: #141c22; } + .gem__code-wrap { margin-top: 12px; position: relative; @@ -179,35 +187,6 @@ .gem__code__icon.static { position: static; } -.gem__code__tooltip--copy, -.gem__code__tooltip--copied { - display: none; } - -.clipboard-is-hover, -.clipboard-is-active { - display: block; - position: absolute; - top: 45px; - right: 0; - width: auto; - padding-left: 10px; - padding-right: 10px; - z-index: 1; - border-radius: 6px; - background-color: #141c22; - text-transform: none; - line-height: 30px; - text-align: center; - color: #ffffff; } - .clipboard-is-hover:before, - .clipboard-is-active:before { - content: ""; - position: absolute; - top: -15px; - right: 8px; - border: 8px solid transparent; - border-bottom: 8px solid #141c22; } - .gem__link:before { margin-right: 16px; } @@ -286,3 +265,23 @@ .gem__dependencies:not(:first-of-type) { margin-top: 36px; } + +.gem__attestation { + display: flex; + flex-direction: row; + margin-top: 16px; } + .gem__attestation__built_on { + flex-grow: 1; } + .gem__attestation__grid { + display: grid; + grid-template-rows: repeat(3, 1rem); + grid-row-gap: 18px; + grid-column-gap: 16px; } + .gem__attestation__grid p { + height: 100%; + display: block; } + .gem__attestation__grid__left { + grid-template-columns: 24px max-content; } + .gem__attestation__grid__right { + grid-template-columns: max-content 1fr; } + diff --git a/app/assets/stylesheets/modules/shared.css b/app/assets/stylesheets/modules/shared.css index 08ece7df3a0..499adb14719 100644 --- a/app/assets/stylesheets/modules/shared.css +++ b/app/assets/stylesheets/modules/shared.css @@ -183,6 +183,20 @@ span.github-btn { margin-top: 5px; } +.recovery-code-list { + border: none; + padding: 10px 20px; + font-size: 1.2em; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + resize: none; + background: none; +} + +.recovery-code-list:focus { + background: none; + outline: none; +} + .recovery-code-list__item { font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } diff --git a/app/assets/stylesheets/tailwind.deprecated.css b/app/assets/stylesheets/tailwind.deprecated.css new file mode 100644 index 00000000000..cbb0ee2e997 --- /dev/null +++ b/app/assets/stylesheets/tailwind.deprecated.css @@ -0,0 +1,6 @@ +/* + * This is the old tailwind build that used `tw-` prefixes. + * We no longer compute this build and the tailwind config for this is gone. + * It's only here until the prefixed `tw-` classes are replaced by the new design. + */ +.tw-mx-auto{margin-left:auto;margin-right:auto}.tw-my-4{margin-bottom:1rem;margin-top:1rem}.\!tw-mb-0{margin-bottom:0!important}.tw-mt-2{margin-top:.5rem}.tw-mt-4{margin-top:1rem}.tw-flex{display:flex}.tw-flex-col{flex-direction:column}.tw-items-baseline{align-items:baseline}.tw-gap-2{gap:.5rem}.tw-gap-4{gap:1rem}.tw-space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.tw-space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.tw-divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.tw-break-all{word-break:break-all}.tw-border{border-width:1px}.tw-border-solid{border-style:solid}.tw-border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity))}.\!tw-bg-white{--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important}.tw-p-5{padding:1.25rem}.\!tw-py-0{padding-bottom:0!important;padding-top:0!important}.\!tw-text-start{text-align:start!important}@media (min-width:640px){.sm\:tw-flex{display:flex}.sm\:tw-grid{display:grid}.sm\:tw-grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:tw-flex-row{flex-direction:row}.sm\:tw-items-baseline{align-items:baseline}} diff --git a/app/avo/actions/add_owner.rb b/app/avo/actions/add_owner.rb index bd98d1b8993..5f853c89e25 100644 --- a/app/avo/actions/add_owner.rb +++ b/app/avo/actions/add_owner.rb @@ -1,10 +1,14 @@ -class AddOwner < BaseAction +class Avo::Actions::AddOwner < Avo::Actions::ApplicationAction self.name = "Add owner" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show } - field :owner, as: :select_record, searchable: true, name: "New owner", use_resource: UserResource + def fields + field :owner, as: :select_record, searchable: true, name: "New owner", use_resource: Avo::Resources::User + + super + end self.message = lambda { "Are you sure you would like to add an owner to #{record.name}?" @@ -12,7 +16,7 @@ class AddOwner < BaseAction self.confirm_button_label = "Add owner" - class ActionHandler < ActionHandler + class ActionHandler < Avo::Actions::ActionHandler set_callback :handle, :before do @owner = fields[:owner] error "Must specify a valid user to add as owner" if @owner.blank? @@ -22,16 +26,16 @@ class ActionHandler < ActionHandler error "Cannot add #{@owner.name} as an owner since they are unconfirmed" if @owner.unconfirmed? end - def do_handle_model(rubygem) + def do_handle_record(rubygem) @rubygem = rubygem super end - set_callback :handle_model, :before do + set_callback :handle_record, :before do error "Cannot add #{@owner.name} as an owner since they are already an owner of #{@rubygem.name}" if @owner.rubygems.include?(@rubygem) end - def handle_model(rubygem) + def handle_record(rubygem) authorizer = User.security_user rubygem.ownerships.create!(user: @owner, authorizer: authorizer, confirmed_at: Time.current) succeed "Added #{@owner.name} to #{@rubygem.name}" diff --git a/app/avo/actions/base_action.rb b/app/avo/actions/application_action.rb similarity index 64% rename from app/avo/actions/base_action.rb rename to app/avo/actions/application_action.rb index 4e985aef5b2..dc5998bd2ac 100644 --- a/app/avo/actions/base_action.rb +++ b/app/avo/actions/application_action.rb @@ -1,17 +1,12 @@ -class BaseAction < Avo::BaseAction +class Avo::Actions::ApplicationAction < Avo::BaseAction include SemanticLogger::Loggable - field :comment, as: :textarea, required: true, - help: "A comment explaining why this action was taken.
Will be saved in the audit log.
Must be more than 10 characters." - - def self.inherited(base) - super - base.items_holder = Avo::ItemsHolder.new - base.items_holder.instance_variable_get(:@items).replace items_holder.instance_variable_get(:@items).deep_dup - base.items_holder.invalid_fields.replace items_holder.invalid_fields.deep_dup + def fields + field :comment, as: :textarea, required: true, + help: "A comment explaining why this action was taken.
Will be saved in the audit log.
Must be more than 10 characters." end - class ActionHandler + class Avo::Actions::ActionHandler include Auditable include SemanticLogger::Loggable @@ -20,7 +15,7 @@ class ActionHandler result_lambda.call target.errored? } - define_callbacks :handle_model, terminator: lambda { |target, result_lambda| + define_callbacks :handle_record, terminator: lambda { |target, result_lambda| result_lambda.call target.errored? } @@ -35,18 +30,20 @@ def initialize( # rubocop:disable Metrics/ParameterLists arguments:, resource:, action:, - models: nil + query:, + records: nil ) - @models = models + @records = records @fields = fields @current_user = current_user @arguments = arguments @resource = resource + @query = query @action = action end - attr_reader :models, :fields, :current_user, :arguments, :resource + attr_reader :records, :fields, :current_user, :arguments, :resource, :query delegate :error, :avo, :keep_modal_open, :redirect_to, :inform, :action_name, :succeed, :logger, to: :@action @@ -60,7 +57,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists block.call rescue StandardError => e Rails.error.report(e, handled: true) - error e.message.truncate(300) + error e.message end } @@ -72,9 +69,17 @@ def do_handle keep_modal_open if errored? end - def do_handle_model(model) - run_callbacks :handle_model do - handle_model(model) + def handle_record(record) + raise NotImplementedError, "#{self.class}#handle_record is not implemented" + end + + def handle_standalone + raise NotImplementedError, "#{self.class}#handle_standalone is not implemented" + end + + def do_handle_record(record) + run_callbacks :handle_record do + handle_record(record) end end @@ -89,7 +94,7 @@ def do_handle_standalone action: action_name, fields:, arguments:, - models: + models: records ) do run_callbacks :handle_standalone do handle_standalone @@ -99,17 +104,17 @@ def do_handle_standalone end def handle - return do_handle_standalone if models.nil? - models.each do |model| + return do_handle_standalone if @action.class.standalone + records.each do |record| _, audit = in_audited_transaction( - auditable: model, + auditable: record, admin_github_user: current_user, action: action_name, fields:, arguments:, - models: + models: records ) do - do_handle_model(model) + do_handle_record(record) end redirect_to avo.resources_audit_path(audit) end @@ -117,9 +122,9 @@ def handle end def handle(**args) - "#{self.class}::ActionHandler" - .constantize + action_handler = self.class.const_get(:ActionHandler) .new(**args, arguments:, action: self) - .do_handle + + action_handler.do_handle end end diff --git a/app/avo/actions/block_user.rb b/app/avo/actions/block_user.rb index d73eb3fb7f8..2ec8e9ca525 100644 --- a/app/avo/actions/block_user.rb +++ b/app/avo/actions/block_user.rb @@ -1,4 +1,4 @@ -class BlockUser < BaseAction +class Avo::Actions::BlockUser < Avo::Actions::ApplicationAction self.name = "Block User" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -10,8 +10,8 @@ class BlockUser < BaseAction self.confirm_button_label = "Block User" - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.block! end end diff --git a/app/avo/actions/change_user_email.rb b/app/avo/actions/change_user_email.rb index 949a0f82feb..5b84a4ddaf0 100644 --- a/app/avo/actions/change_user_email.rb +++ b/app/avo/actions/change_user_email.rb @@ -1,5 +1,8 @@ -class ChangeUserEmail < BaseAction - field :from_email, name: "Email", as: :text, required: true +class Avo::Actions::ChangeUserEmail < Avo::Actions::ApplicationAction + def fields + field :from_email, name: "Email", as: :text, required: true + super + end self.name = "Change User Email" self.visible = lambda { @@ -8,8 +11,8 @@ class ChangeUserEmail < BaseAction self.confirm_button_label = "Change User Email" - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.email = fields["from_email"] user.email_confirmed = false user.generate_confirmation_token diff --git a/app/avo/actions/create_user.rb b/app/avo/actions/create_user.rb index 8abd11d3969..80ec4ed66e5 100644 --- a/app/avo/actions/create_user.rb +++ b/app/avo/actions/create_user.rb @@ -1,6 +1,4 @@ -class CreateUser < BaseAction - field :email, name: "Email", as: :text, required: true - +class Avo::Actions::CreateUser < Avo::Actions::ApplicationAction self.name = "Create User" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :index && !Rails.env.production? @@ -9,7 +7,12 @@ class CreateUser < BaseAction self.confirm_button_label = "Create User" - class ActionHandler < ActionHandler + def fields + field :email, name: "Email", as: :text, required: true + super + end + + class ActionHandler < Avo::Actions::ActionHandler def handle_standalone user = User.new( email: fields["email"], diff --git a/app/avo/actions/delete_webhook.rb b/app/avo/actions/delete_webhook.rb index 4a696fbc5c8..da6959c8ed3 100644 --- a/app/avo/actions/delete_webhook.rb +++ b/app/avo/actions/delete_webhook.rb @@ -1,4 +1,4 @@ -class DeleteWebhook < BaseAction +class Avo::Actions::DeleteWebhook < Avo::Actions::ApplicationAction self.name = "Delete Webhook" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -10,8 +10,8 @@ class DeleteWebhook < BaseAction self.confirm_button_label = "Delete Webhook" - class ActionHandler < ActionHandler - def handle_model(webhook) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(webhook) webhook.destroy! WebHooksMailer.webhook_deleted(webhook.user_id, webhook.rubygem_id, webhook.url, webhook.failure_count).deliver_later end diff --git a/app/avo/actions/onboard_organization.rb b/app/avo/actions/onboard_organization.rb new file mode 100644 index 00000000000..96708a56a6a --- /dev/null +++ b/app/avo/actions/onboard_organization.rb @@ -0,0 +1,16 @@ +class Avo::Actions::OnboardOrganization < Avo::Actions::ApplicationAction + self.name = "Onboard Organization" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show + } + + self.message = lambda { + "Are you sure you would like to onboard this organization?" + } + + self.confirm_button_label = "Onboard" + + def handle(query:, **_) + query.each(&:onboard!) + end +end diff --git a/app/avo/actions/refresh_oidc_provider.rb b/app/avo/actions/refresh_oidc_provider.rb index c53e53d766f..eac8c960589 100644 --- a/app/avo/actions/refresh_oidc_provider.rb +++ b/app/avo/actions/refresh_oidc_provider.rb @@ -1,4 +1,4 @@ -class RefreshOIDCProvider < BaseAction +class Avo::Actions::RefreshOIDCProvider < Avo::Actions::ApplicationAction self.name = "Refresh OIDC Provider" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -10,8 +10,8 @@ class RefreshOIDCProvider < BaseAction self.confirm_button_label = "Refresh" - class ActionHandler < ActionHandler - def handle_model(provider) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(provider) RefreshOIDCProviderJob.perform_now(provider:) end end diff --git a/app/avo/actions/release_reserved_namespace.rb b/app/avo/actions/release_reserved_namespace.rb index 1cbbe8b793c..8ae227a433f 100644 --- a/app/avo/actions/release_reserved_namespace.rb +++ b/app/avo/actions/release_reserved_namespace.rb @@ -1,4 +1,4 @@ -class ReleaseReservedNamespace < BaseAction +class Avo::Actions::ReleaseReservedNamespace < Avo::Actions::ApplicationAction self.name = "Release reserved namespace" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -10,8 +10,8 @@ class ReleaseReservedNamespace < BaseAction self.confirm_button_label = "Release namespace" - class ActionHandler < ActionHandler - def handle_model(rubygem) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(rubygem) rubygem.release_reserved_namespace! end end diff --git a/app/avo/actions/reset_api_key.rb b/app/avo/actions/reset_api_key.rb index 9f5240c4958..51b89340bb3 100644 --- a/app/avo/actions/reset_api_key.rb +++ b/app/avo/actions/reset_api_key.rb @@ -1,4 +1,4 @@ -class ResetApiKey < BaseAction +class Avo::Actions::ResetApiKey < Avo::Actions::ApplicationAction self.name = "Reset Api Key" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -8,15 +8,18 @@ class ResetApiKey < BaseAction } self.confirm_button_label = "Reset Api Key" - field :template, as: :select, - options: { - "Public Gem": :public_gem_reset_api_key, - Honeycomb: :honeycomb_reset_api_key - }, - help: "Select mailer template" + def fields + field :template, as: :select, + options: { + "Public Gem": :public_gem_reset_api_key, + Honeycomb: :honeycomb_reset_api_key + }, + help: "Select mailer template" + super + end - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.reset_api_key! Mailer.reset_api_key(user, fields["template"]).deliver_later diff --git a/app/avo/actions/reset_user_2fa.rb b/app/avo/actions/reset_user_2fa.rb index 03e19a1d518..c775f717593 100644 --- a/app/avo/actions/reset_user_2fa.rb +++ b/app/avo/actions/reset_user_2fa.rb @@ -1,4 +1,4 @@ -class ResetUser2fa < BaseAction +class Avo::Actions::ResetUser2fa < Avo::Actions::ApplicationAction self.name = "Reset User 2FA" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -10,8 +10,8 @@ class ResetUser2fa < BaseAction self.confirm_button_label = "Reset MFA" - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.disable_totp! user.password = SecureRandom.hex(20).encode("UTF-8") user.save! diff --git a/app/avo/actions/restore_version.rb b/app/avo/actions/restore_version.rb index 2a5bf7f250f..bfb80bdba34 100644 --- a/app/avo/actions/restore_version.rb +++ b/app/avo/actions/restore_version.rb @@ -1,17 +1,17 @@ -class RestoreVersion < BaseAction +class Avo::Actions::RestoreVersion < Avo::Actions::ApplicationAction self.name = "Restore version" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show && - resource.model.deletion.present? + resource.record.deletion.present? } self.message = lambda { "Are you sure you would like to restore #{record.slug} with " } self.confirm_button_label = "Restore version" - class ActionHandler < ActionHandler - def handle_model(version) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(version) version.deletion&.restore! end end diff --git a/app/avo/actions/unblock_user.rb b/app/avo/actions/unblock_user.rb new file mode 100644 index 00000000000..d8e7305bb07 --- /dev/null +++ b/app/avo/actions/unblock_user.rb @@ -0,0 +1,18 @@ +class Avo::Actions::UnblockUser < Avo::Actions::ApplicationAction + self.name = "Unblock User" + self.visible = lambda { + current_user.team_member?("rubygems-org") && view == :show && record.blocked? + } + + self.message = lambda { + "Are you sure you would like to unblock user #{record.handle} with #{record.blocked_email}?" + } + + self.confirm_button_label = "Unblock User" + + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) + user.unblock! + end + end +end diff --git a/app/avo/actions/upload_info_file.rb b/app/avo/actions/upload_info_file.rb index 38740af0a4f..264dd083ac9 100644 --- a/app/avo/actions/upload_info_file.rb +++ b/app/avo/actions/upload_info_file.rb @@ -1,12 +1,12 @@ -class UploadInfoFile < BaseAction +class Avo::Actions::UploadInfoFile < Avo::Actions::ApplicationAction self.name = "Upload Info File" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show } self.confirm_button_label = "Upload" - class ActionHandler < ActionHandler - def handle_model(rubygem) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(rubygem) UploadInfoFileJob.perform_later(rubygem_name: rubygem.name) succeed("Upload job scheduled") diff --git a/app/avo/actions/upload_names_file.rb b/app/avo/actions/upload_names_file.rb index 1ab53b4ce95..c9eac66a398 100644 --- a/app/avo/actions/upload_names_file.rb +++ b/app/avo/actions/upload_names_file.rb @@ -1,4 +1,4 @@ -class UploadNamesFile < BaseAction +class Avo::Actions::UploadNamesFile < Avo::Actions::ApplicationAction self.name = "Upload Names File" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :index @@ -6,7 +6,7 @@ class UploadNamesFile < BaseAction self.standalone = true self.confirm_button_label = "Upload" - class ActionHandler < ActionHandler + class ActionHandler < Avo::Actions::ActionHandler def handle_standalone UploadNamesFileJob.perform_later diff --git a/app/avo/actions/upload_versions_file.rb b/app/avo/actions/upload_versions_file.rb index ee5f700a02a..7a14e02a864 100644 --- a/app/avo/actions/upload_versions_file.rb +++ b/app/avo/actions/upload_versions_file.rb @@ -1,4 +1,4 @@ -class UploadVersionsFile < BaseAction +class Avo::Actions::UploadVersionsFile < Avo::Actions::ApplicationAction self.name = "Upload Versions File" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :index @@ -6,7 +6,7 @@ class UploadVersionsFile < BaseAction self.standalone = true self.confirm_button_label = "Upload" - class ActionHandler < ActionHandler + class ActionHandler < Avo::Actions::ActionHandler def handle_standalone UploadVersionsFileJob.perform_later diff --git a/app/avo/actions/version_after_write.rb b/app/avo/actions/version_after_write.rb index 94490970847..cfbe40a9933 100644 --- a/app/avo/actions/version_after_write.rb +++ b/app/avo/actions/version_after_write.rb @@ -1,9 +1,9 @@ -class VersionAfterWrite < BaseAction +class Avo::Actions::VersionAfterWrite < Avo::Actions::ApplicationAction self.name = "Run version post-write job" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show && - resource.model.deletion.blank? + resource.record.deletion.blank? } self.message = lambda { @@ -12,8 +12,8 @@ class VersionAfterWrite < BaseAction self.confirm_button_label = "Run Job" - class ActionHandler < ActionHandler - def handle_model(version) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(version) AfterVersionWriteJob.new(version: version).perform(version: version) end end diff --git a/app/avo/actions/yank_rubygem.rb b/app/avo/actions/yank_rubygem.rb index 21c9771ef37..ddc86f8a855 100644 --- a/app/avo/actions/yank_rubygem.rb +++ b/app/avo/actions/yank_rubygem.rb @@ -1,17 +1,18 @@ -class YankRubygem < BaseAction +class Avo::Actions::YankRubygem < Avo::Actions::ApplicationAction OPTION_ALL = "All".freeze - field :version, as: :select, - options: lambda { |model:, resource:, view:, field:| # rubocop:disable Lint/UnusedBlockArgument - [OPTION_ALL] + model.versions.indexed.pluck(:number, :id) - }, - help: "Select Version which needs to be yanked." + def fields + field :version, as: :select, + options: -> { [OPTION_ALL] + record.versions.indexed.pluck(:number, :id) }, + help: "Select Version which needs to be yanked." + super + end self.name = "Yank Rubygem" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show && - resource.model.versions.indexed.present? + resource.record.versions.indexed.present? } self.message = lambda { @@ -20,8 +21,8 @@ class YankRubygem < BaseAction self.confirm_button_label = "Yank Rubygem" - class ActionHandler < ActionHandler - def handle_model(rubygem) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(rubygem) version_id = fields["version"] version_id_to_yank = version_id if version_id != OPTION_ALL diff --git a/app/avo/actions/yank_rubygems_for_user.rb b/app/avo/actions/yank_rubygems_for_user.rb index d6f5e00e334..ba3edb0b157 100644 --- a/app/avo/actions/yank_rubygems_for_user.rb +++ b/app/avo/actions/yank_rubygems_for_user.rb @@ -1,9 +1,9 @@ -class YankRubygemsForUser < BaseAction +class Avo::Actions::YankRubygemsForUser < Avo::Actions::ApplicationAction self.name = "Yank all Rubygems" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show && - resource.model.rubygems.present? + resource.record.rubygems.present? } self.message = lambda { @@ -12,8 +12,8 @@ class YankRubygemsForUser < BaseAction self.confirm_button_label = "Yank all Rubygems" - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.rubygems.find_each do |rubygem| rubygem.yank_versions!(force: true) end diff --git a/app/avo/actions/yank_user.rb b/app/avo/actions/yank_user.rb index 1f4f34acdf8..9a0612048cf 100644 --- a/app/avo/actions/yank_user.rb +++ b/app/avo/actions/yank_user.rb @@ -1,4 +1,4 @@ -class YankUser < BaseAction +class Avo::Actions::YankUser < Avo::Actions::ApplicationAction self.name = "Yank User" self.visible = lambda { current_user.team_member?("rubygems-org") && view == :show @@ -8,8 +8,8 @@ class YankUser < BaseAction } self.confirm_button_label = "Yank User" - class ActionHandler < ActionHandler - def handle_model(user) + class ActionHandler < Avo::Actions::ActionHandler + def handle_record(user) user.rubygems.find_each do |rubygem| rubygem.yank_versions!(force: true) end diff --git a/app/avo/cards/dashboard_welcome_card.rb b/app/avo/cards/dashboard_welcome_card.rb index 8cbadb73c47..e5d37e51490 100644 --- a/app/avo/cards/dashboard_welcome_card.rb +++ b/app/avo/cards/dashboard_welcome_card.rb @@ -1,4 +1,4 @@ -class DashboardWelcomeCard < Avo::Dashboards::PartialCard +class Avo::Cards::DashboardWelcomeCard < Avo::Cards::PartialCard self.id = "dashboard_welcome_card" self.label = "Welcome to the RubyGems.org admin dashboard!" self.partial = "avo/cards/dashboard_welcome_card" diff --git a/app/avo/cards/pushes_chart.rb b/app/avo/cards/pushes_chart.rb index 5d45743df0b..523970c762f 100644 --- a/app/avo/cards/pushes_chart.rb +++ b/app/avo/cards/pushes_chart.rb @@ -1,4 +1,4 @@ -class PushesChart < Avo::Dashboards::ChartkickCard +class Avo::Cards::PushesChart < Avo::Cards::ChartkickCard self.id = "pushes_chart" self.label = "Pushes by day" self.chart_type = :line_chart diff --git a/app/avo/cards/rubygems_metric.rb b/app/avo/cards/rubygems_metric.rb index 03f9dfc57a6..cd74a871bed 100644 --- a/app/avo/cards/rubygems_metric.rb +++ b/app/avo/cards/rubygems_metric.rb @@ -1,4 +1,4 @@ -class RubygemsMetric < Avo::Dashboards::MetricCard +class Avo::Cards::RubygemsMetric < Avo::Cards::MetricCard self.id = "rubygems_metric" self.label = "RubyGems " self.cols = 2 diff --git a/app/avo/cards/users_metric.rb b/app/avo/cards/users_metric.rb index 18033d49463..e9eb71941f2 100644 --- a/app/avo/cards/users_metric.rb +++ b/app/avo/cards/users_metric.rb @@ -1,4 +1,4 @@ -class UsersMetric < Avo::Dashboards::MetricCard +class Avo::Cards::UsersMetric < Avo::Cards::MetricCard self.id = "users_metric" self.label = "Total users" self.cols = 2 diff --git a/app/avo/cards/versions_metric.rb b/app/avo/cards/versions_metric.rb index fefa912a30f..0c245831f40 100644 --- a/app/avo/cards/versions_metric.rb +++ b/app/avo/cards/versions_metric.rb @@ -1,4 +1,4 @@ -class VersionsMetric < Avo::Dashboards::MetricCard +class Avo::Cards::VersionsMetric < Avo::Cards::MetricCard self.id = "versions_metric" self.label = "Versions pushed" diff --git a/app/avo/dashboards/dashy.rb b/app/avo/dashboards/dashy.rb index 3a5ef54376c..18e8c43ebff 100644 --- a/app/avo/dashboards/dashy.rb +++ b/app/avo/dashboards/dashy.rb @@ -1,21 +1,22 @@ -class Dashy < Avo::Dashboards::BaseDashboard +class Avo::Dashboards::Dashy < Avo::Dashboards::BaseDashboard self.id = "dashy" - self.name = "Dashy" + self.name = "Avo::Dashboards::Dashy" self.grid_cols = 6 self.visible = lambda { current_user.team_member?("rubygems-org") } - # cards go here - card DashboardWelcomeCard + def cards + card Avo::Cards::DashboardWelcomeCard - divider label: "Metrics" + divider label: "Metrics" - card UsersMetric - card VersionsMetric - card RubygemsMetric + card Avo::Cards::UsersMetric + card Avo::Cards::VersionsMetric + card Avo::Cards::RubygemsMetric - divider label: "Charts" + divider label: "Charts" - card PushesChart + card Avo::Cards::PushesChart + end end diff --git a/app/avo/fields/array_of_field.rb b/app/avo/fields/array_of_field.rb index 7688f1662ed..d86dba064d2 100644 --- a/app/avo/fields/array_of_field.rb +++ b/app/avo/fields/array_of_field.rb @@ -1,9 +1,9 @@ -class ArrayOfField < Avo::Fields::BaseField +class Avo::Fields::ArrayOfField < Avo::Fields::BaseField def initialize(name, field:, field_options: {}, **args, &block) super(name, **args, &nil) @make_field = lambda do |id:, index: nil, value: nil| - items_holder = Avo::ItemsHolder.new + items_holder = Avo::Resources::Items::Holder.new items_holder.field(id, name: index&.to_s || self.name, as: field, required: -> { false }, value:, **field_options, &block) items_holder.items.sole.hydrate(view:, resource:) end diff --git a/app/avo/fields/audited_changes_field.rb b/app/avo/fields/audited_changes_field.rb index 4d4398abcb3..3f4e8b522bb 100644 --- a/app/avo/fields/audited_changes_field.rb +++ b/app/avo/fields/audited_changes_field.rb @@ -1,2 +1,2 @@ -class AuditedChangesField < Avo::Fields::BaseField +class Avo::Fields::AuditedChangesField < Avo::Fields::BaseField end diff --git a/app/avo/fields/event_additional_field.rb b/app/avo/fields/event_additional_field.rb index 3e42dc93235..faf2f30f69f 100644 --- a/app/avo/fields/event_additional_field.rb +++ b/app/avo/fields/event_additional_field.rb @@ -1,13 +1,13 @@ -class EventAdditionalField < Avo::Fields::BaseField +class Avo::Fields::EventAdditionalField < Avo::Fields::BaseField def nested_field - return unless @model - additional_type = @model.additional_type + return unless record + additional_type = record.additional_type if additional_type.nil? return JsonViewerField.new(id, **@args) - .hydrate(model:, resource:, action:, view:, panel_name:, user:) + .hydrate(record:, resource:, action:, view:, panel_name:, user:) end - NestedField.new(id, **@args) do + Avo::Fields::NestedField.new(id, **@args) do additional_type.attribute_types.each do |attribute_name, type| case type when Types::GlobalId @@ -20,7 +20,7 @@ def nested_field field attribute_name.to_sym, as: :json_viewer, hide_on: :index end end - end.hydrate(model:, resource:, action:, view:, panel_name:, user:) + end.hydrate(record:, resource:, action:, view:, panel_name:, user:) end methods = %i[fill_field value update_using to_permitted_param component_for_view visible get_fields] diff --git a/app/avo/fields/global_id_field.rb b/app/avo/fields/global_id_field.rb index 0f72f5637af..59cce30fcbc 100644 --- a/app/avo/fields/global_id_field.rb +++ b/app/avo/fields/global_id_field.rb @@ -1,4 +1,4 @@ -class GlobalIdField < Avo::Fields::BelongsToField +class Avo::Fields::GlobalIdField < Avo::Fields::BelongsToField include SemanticLogger::Loggable delegate(*%i[values_for_type custom?], to: :@nil) diff --git a/app/avo/fields/json_viewer_field.rb b/app/avo/fields/json_viewer_field.rb index df4d52e45d3..aedb7030e5f 100644 --- a/app/avo/fields/json_viewer_field.rb +++ b/app/avo/fields/json_viewer_field.rb @@ -1,4 +1,4 @@ -class JsonViewerField < Avo::Fields::CodeField +class Avo::Fields::JsonViewerField < Avo::Fields::CodeField def initialize(name, **args, &) super(name, **args, language: :javascript, line_wrapping: true, &) end diff --git a/app/avo/fields/nested_field.rb b/app/avo/fields/nested_field.rb index 769216a026a..c0078d70892 100644 --- a/app/avo/fields/nested_field.rb +++ b/app/avo/fields/nested_field.rb @@ -1,8 +1,8 @@ -class NestedField < Avo::Fields::BaseField - include Avo::Concerns::HasFields +class Avo::Fields::NestedField < Avo::Fields::BaseField + include Avo::Concerns::HasItems def initialize(name, stacked: true, **args, &block) - @items_holder = Avo::ItemsHolder.new + @items_holder = Avo::Resources::Items::Holder.new hide_on :index super(name, stacked:, **args, &nil) instance_exec(&block) if block diff --git a/app/avo/fields/select_record_field.rb b/app/avo/fields/select_record_field.rb index 83da8985919..e4d07e2373e 100644 --- a/app/avo/fields/select_record_field.rb +++ b/app/avo/fields/select_record_field.rb @@ -1,4 +1,4 @@ -class SelectRecordField < Avo::Fields::BelongsToField +class Avo::Fields::SelectRecordField < Avo::Fields::BelongsToField def foreign_key id end @@ -7,4 +7,8 @@ def resolve_attribute(value) return if value.blank? target_resource.find_record value end + + def form_field_label + is_searchable? ? id : super + end end diff --git a/app/avo/filters/email_filter.rb b/app/avo/filters/email_filter.rb index 2a3375c86c5..2cbc9e19912 100644 --- a/app/avo/filters/email_filter.rb +++ b/app/avo/filters/email_filter.rb @@ -1,4 +1,4 @@ -class EmailFilter < Avo::Filters::TextFilter +class Avo::Filters::EmailFilter < Avo::Filters::TextFilter self.name = "Email filter" self.button_label = "Filter by email" diff --git a/app/avo/filters/scope_boolean_filter.rb b/app/avo/filters/scope_boolean_filter.rb index 6addbbcb400..67543347576 100644 --- a/app/avo/filters/scope_boolean_filter.rb +++ b/app/avo/filters/scope_boolean_filter.rb @@ -1,4 +1,4 @@ -class ScopeBooleanFilter < Avo::Filters::BooleanFilter +class Avo::Filters::ScopeBooleanFilter < Avo::Filters::BooleanFilter def name arguments.fetch(:name) { self.class.to_s.demodulize.underscore.sub(/_filter$/, "").titleize } end diff --git a/app/avo/resources/admin_github_user.rb b/app/avo/resources/admin_github_user.rb new file mode 100644 index 00000000000..1f90f36b113 --- /dev/null +++ b/app/avo/resources/admin_github_user.rb @@ -0,0 +1,34 @@ +class Avo::Resources::AdminGitHubUser < Avo::BaseResource + self.title = :login + self.includes = [] + self.model_class = ::Admin::GitHubUser + self.search = { + query: lambda { + query.where("login LIKE ?", "%#{params[:q]}%") + } + } + + self.description = "GitHub users that have authenticated via the admin OAuth flow." + + def fields + field :id, as: :id + + field :is_admin, as: :boolean, readonly: true + field :login, as: :text, readonly: true, + as_html: true, + format_using: -> { link_to value, "https://github.com/#{value}" } + field :avatar_url, as: :external_image, name: "Avatar", readonly: true + field :github_id, as: :text, readonly: true + field :oauth_token, as: :text, visible: -> { false } + + field :details, as: :heading + + field :teams, as: :tags, readonly: true, format_using: -> { value.pluck(:slug) } + + field :info_data, + as: :code, readonly: true, language: :javascript, + format_using: -> { JSON.pretty_generate value } + + field :audits, as: :has_many + end +end diff --git a/app/avo/resources/admin_github_user_resource.rb b/app/avo/resources/admin_github_user_resource.rb deleted file mode 100644 index 65a1e6c79e3..00000000000 --- a/app/avo/resources/admin_github_user_resource.rb +++ /dev/null @@ -1,31 +0,0 @@ -class AdminGitHubUserResource < Avo::BaseResource - self.title = :login - self.includes = [] - self.model_class = ::Admin::GitHubUser - self.authorization_policy = ::Admin::GitHubUserPolicy - self.search_query = lambda { - scope.where("login LIKE ?", "%#{params[:q]}%") - } - - self.description = "GitHub users that have authenticated via the admin OAuth flow." - - field :id, as: :id - - field :is_admin, as: :boolean, readonly: true - field :login, as: :text, readonly: true, - as_html: true, - format_using: -> { link_to value, "https://github.com/#{value}" } - field :avatar_url, as: :external_image, name: "Avatar", readonly: true - field :github_id, as: :text, readonly: true - field :oauth_token, as: :text, visible: ->(resource:) { false } # rubocop:disable Lint/UnusedBlockArgument - - heading "Details" - - field :teams, as: :tags, readonly: true, format_using: -> { value.pluck(:slug) } - - field :info_data, - as: :code, readonly: true, language: :javascript, - format_using: -> { JSON.pretty_generate value } - - field :audits, as: :has_many -end diff --git a/app/avo/resources/api_key.rb b/app/avo/resources/api_key.rb new file mode 100644 index 00000000000..c3c2251565e --- /dev/null +++ b/app/avo/resources/api_key.rb @@ -0,0 +1,46 @@ +class Avo::Resources::ApiKey < Avo::BaseResource + self.title = :name + self.includes = [] + + class ExpiredFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } + end + + def fields + main_panel do + field :id, as: :id, hide_on: :index + + field :name, as: :text, link_to_resource: true + field :hashed_key, as: :text, visible: -> { false } + field :user, as: :belongs_to, visible: -> { false } + field :owner, as: :belongs_to, + polymorphic_as: :owner, + types: [::User, ::OIDC::TrustedPublisher::GitHubAction] + field :last_accessed_at, as: :date_time + field :soft_deleted_at, as: :date_time + field :soft_deleted_rubygem_name, as: :text + field :expires_at, as: :date_time + + field :enabled_scopes, as: :tags + + sidebar do + field :permissions, as: :heading + + field :index_rubygems, as: :boolean + field :push_rubygem, as: :boolean + field :yank_rubygem, as: :boolean + field :add_owner, as: :boolean + field :remove_owner, as: :boolean + field :access_webhooks, as: :boolean + field :show_dashboard, as: :boolean + field :mfa, as: :boolean + end + end + + field :api_key_rubygem_scope, as: :has_one + field :ownership, as: :has_one + field :oidc_id_token, as: :has_one + end +end diff --git a/app/avo/resources/api_key_resource.rb b/app/avo/resources/api_key_resource.rb deleted file mode 100644 index dfcec27ceb2..00000000000 --- a/app/avo/resources/api_key_resource.rb +++ /dev/null @@ -1,39 +0,0 @@ -class ApiKeyResource < Avo::BaseResource - self.title = :name - self.includes = [] - - class ExpiredFilter < ScopeBooleanFilter; end - filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } - - field :id, as: :id, hide_on: :index - - field :name, as: :text, link_to_resource: true - field :hashed_key, as: :text, visible: ->(_) { false } - field :user, as: :belongs_to, visible: ->(_) { false } - field :owner, as: :belongs_to, - polymorphic_as: :owner, - types: ["User", "OIDC::TrustedPublisher::GitHubAction"] - field :last_accessed_at, as: :date_time - field :soft_deleted_at, as: :date_time - field :soft_deleted_rubygem_name, as: :text - field :expires_at, as: :date_time - - field :scopes, as: :tags - - sidebar do - heading "Permissions" - - field :index_rubygems, as: :boolean - field :push_rubygem, as: :boolean - field :yank_rubygem, as: :boolean - field :add_owner, as: :boolean - field :remove_owner, as: :boolean - field :access_webhooks, as: :boolean - field :show_dashboard, as: :boolean - field :mfa, as: :boolean - end - - field :api_key_rubygem_scope, as: :has_one - field :ownership, as: :has_one - field :oidc_id_token, as: :has_one -end diff --git a/app/avo/resources/api_key_rubygem_scope.rb b/app/avo/resources/api_key_rubygem_scope.rb new file mode 100644 index 00000000000..e8105285828 --- /dev/null +++ b/app/avo/resources/api_key_rubygem_scope.rb @@ -0,0 +1,11 @@ +class Avo::Resources::ApiKeyRubygemScope < Avo::BaseResource + self.title = :cache_key + self.includes = [] + + def fields + field :id, as: :id + + field :api_key, as: :belongs_to + field :ownership, as: :belongs_to + end +end diff --git a/app/avo/resources/api_key_rubygem_scope_resource.rb b/app/avo/resources/api_key_rubygem_scope_resource.rb deleted file mode 100644 index 9560edba16c..00000000000 --- a/app/avo/resources/api_key_rubygem_scope_resource.rb +++ /dev/null @@ -1,9 +0,0 @@ -class ApiKeyRubygemScopeResource < Avo::BaseResource - self.title = :cache_key - self.includes = [] - - field :id, as: :id - - field :api_key, as: :belongs_to - field :ownership, as: :belongs_to -end diff --git a/app/avo/resources/attestation.rb b/app/avo/resources/attestation.rb new file mode 100644 index 00000000000..132f3b9d2ac --- /dev/null +++ b/app/avo/resources/attestation.rb @@ -0,0 +1,20 @@ +class Avo::Resources::Attestation < Avo::BaseResource + self.title = :id + self.includes = [:version] + + def fields + field :id, as: :id + + field :version, as: :belongs_to + field :media_type, as: :text + field :body, as: :json_viewer + + field :leaf_certificate, as: :code, only_on: :show do + record.sigstore_bundle.leaf_certificate.to_text + end + + field :display_data, as: :json_viewer, only_on: :show do + record.display_data + end + end +end diff --git a/app/avo/resources/audit.rb b/app/avo/resources/audit.rb new file mode 100644 index 00000000000..4395346edd1 --- /dev/null +++ b/app/avo/resources/audit.rb @@ -0,0 +1,47 @@ +class Avo::Resources::Audit < Avo::BaseResource + self.includes = %i[ + admin_github_user + auditable + ] + + def fields + main_panel do + field :action, as: :text + + if defined?(Avo::Pro) + sidebar do + panel_sidebar_contents + end + else + panel_sidebar_contents + end + + field :audited_changes, as: :audited_changes, except_on: :index + end + end + + def panel_sidebar_contents + field :admin_github_user, as: :belongs_to + field :created_at, as: :date_time + field :comment, as: :text + + field :auditable, as: :belongs_to, + polymorphic_as: :auditable, + types: [::User, ::WebHook], + name: "Edited Record" + + field :action_details, as: :heading + + field :audited_changes_arguments, as: :json_viewer, only_on: :show do |_model| + record.audited_changes["arguments"] + end + field :audited_changes_fields, as: :json_viewer, only_on: :show do |_model| + record.audited_changes["fields"] + end + field :audited_changes_models, as: :text, as_html: true, only_on: :show do + record.audited_changes["models"] + end + + field :id, as: :id + end +end diff --git a/app/avo/resources/audit_resource.rb b/app/avo/resources/audit_resource.rb deleted file mode 100644 index 548729de209..00000000000 --- a/app/avo/resources/audit_resource.rb +++ /dev/null @@ -1,36 +0,0 @@ -class AuditResource < Avo::BaseResource - self.title = :id - self.includes = %i[ - admin_github_user - auditable - ] - - field :action, as: :text - - sidebar do - field :admin_github_user, as: :belongs_to - field :created_at, as: :date_time - field :comment, as: :text - - field :auditable, as: :belongs_to, - polymorphic_as: :auditable, - types: %w[User WebHook], - name: "Edited Record" - - heading "Action Details" - - field :audited_changes_arguments, as: :json_viewer, only_on: :show do |model| - model.audited_changes["arguments"] - end - field :audited_changes_fields, as: :json_viewer, only_on: :show do |model| - model.audited_changes["fields"] - end - field :audited_changes_models, as: :text, as_html: true, only_on: :show do - model.audited_changes["models"] - end - - field :id, as: :id - end - - field :audited_changes, as: :audited_changes, except_on: :index -end diff --git a/app/avo/resources/concerns/avo_auditable_resource.rb b/app/avo/resources/concerns/avo_auditable_resource.rb index 5eb219fb67b..df3630780a4 100644 --- a/app/avo/resources/concerns/avo_auditable_resource.rb +++ b/app/avo/resources/concerns/avo_auditable_resource.rb @@ -1,20 +1,24 @@ -module Concerns::AvoAuditableResource +module Avo::Resources::Concerns::AvoAuditableResource extend ActiveSupport::Concern - class_methods do - def inherited(base) - super - base.items_holder = Avo::ItemsHolder.new - base.items_holder.instance_variable_get(:@items).replace items_holder.instance_variable_get(:@items).deep_dup - base.items_holder.invalid_fields.replace items_holder.invalid_fields.deep_dup - end + def fetch_fields + super + return unless view.form? + + field :comment, as: :textarea, required: true, + help: "A comment explaining why this action was taken.
Will be saved in the audit log.
Must be more than 10 characters." end - included do - panel "Auditable" do - field :comment, as: :textarea, required: true, - help: "A comment explaining why this action was taken.
Will be saved in the audit log.
Must be more than 10 characters.", - only_on: %i[new edit] + # Would be nice if there was a way to force a field to show up as visible + module HasItemsIncludeComment + def visible_items + items = super + + if view.form? + comment = self.items.find { |item| item.respond_to?(:id) && item.id == :comment } + items << comment if comment + end + items end end end diff --git a/app/avo/resources/deletion.rb b/app/avo/resources/deletion.rb new file mode 100644 index 00000000000..d406c74d99a --- /dev/null +++ b/app/avo/resources/deletion.rb @@ -0,0 +1,14 @@ +class Avo::Resources::Deletion < Avo::BaseResource + self.includes = [:version] + + def fields + field :id, as: :id + + field :created_at, as: :date_time, sortable: true, title: "Deleted At" + field :rubygem, as: :text + field :number, as: :text + field :platform, as: :text + field :user, as: :belongs_to + field :version, as: :belongs_to + end +end diff --git a/app/avo/resources/deletion_resource.rb b/app/avo/resources/deletion_resource.rb deleted file mode 100644 index fdf0cca5387..00000000000 --- a/app/avo/resources/deletion_resource.rb +++ /dev/null @@ -1,17 +0,0 @@ -class DeletionResource < Avo::BaseResource - self.title = :id - self.includes = [:version] - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - field :id, as: :id - # Fields generated from the model - field :created_at, as: :date_time, sortable: true, title: "Deleted At" - field :rubygem, as: :text - field :number, as: :text - field :platform, as: :text - field :user, as: :belongs_to - field :version, as: :belongs_to - # add fields here -end diff --git a/app/avo/resources/dependency.rb b/app/avo/resources/dependency.rb new file mode 100644 index 00000000000..b5e364ba7a2 --- /dev/null +++ b/app/avo/resources/dependency.rb @@ -0,0 +1,17 @@ +class Avo::Resources::Dependency < Avo::BaseResource + self.includes = [] + + def fields + field :id, as: :id, link_to_resource: true + + field :version, as: :belongs_to + field :rubygem, as: :belongs_to + field :requirements, as: :text + field :unresolved_name, as: :text + + field :scope, as: :badge, + options: { + warning: "development" + } + end +end diff --git a/app/avo/resources/dependency_resource.rb b/app/avo/resources/dependency_resource.rb deleted file mode 100644 index a3751556d8f..00000000000 --- a/app/avo/resources/dependency_resource.rb +++ /dev/null @@ -1,19 +0,0 @@ -class DependencyResource < Avo::BaseResource - self.title = :id - self.includes = [] - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - field :id, as: :id, link_to_resource: true - - field :version, as: :belongs_to - field :rubygem, as: :belongs_to - field :requirements, as: :text - field :unresolved_name, as: :text - - field :scope, as: :badge, - options: { - warning: "development" - } -end diff --git a/app/avo/resources/events_rubygem_event.rb b/app/avo/resources/events_rubygem_event.rb new file mode 100644 index 00000000000..74ab7bc5c19 --- /dev/null +++ b/app/avo/resources/events_rubygem_event.rb @@ -0,0 +1,30 @@ +class Avo::Resources::EventsRubygemEvent < Avo::BaseResource + self.title = :cache_key + self.includes = %i[rubygem ip_address geoip_info] + self.model_class = "Events::RubygemEvent" + + def fields + field :id, as: :id, hide_on: :index + field :created_at, as: :date_time + + field :trace_id, as: :text, format_using: proc { + if value.present? + link_to( + view == :index ? "🔗" : value, + "https://app.datadoghq.com/logs?query=#{{ + :@traceid => value, + from_ts: (record.created_at - 12.hours).to_i * 1000, + to_ts: (record.created_at + 12.hours).to_i * 1000 + }.to_query}", + { target: :_blank, rel: :noopener } + ) + end + } + + field :tag, as: :text + field :rubygem, as: :belongs_to + field :ip_address, as: :belongs_to + field :geoip_info, as: :belongs_to + field :additional, as: :event_additional, show_on: :index + end +end diff --git a/app/avo/resources/events_rubygem_event_resource.rb b/app/avo/resources/events_rubygem_event_resource.rb deleted file mode 100644 index 00e1c212574..00000000000 --- a/app/avo/resources/events_rubygem_event_resource.rb +++ /dev/null @@ -1,28 +0,0 @@ -class EventsRubygemEventResource < Avo::BaseResource - self.title = :cache_key - self.includes = %i[rubygem ip_address geoip_info] - self.model_class = ::Events::RubygemEvent - - field :id, as: :id, hide_on: :index - field :created_at, as: :date_time - - field :trace_id, as: :text, format_using: proc { - if value.present? - link_to( - view == :index ? "🔗" : value, - "https://app.datadoghq.com/logs?query=#{{ - :@traceid => value, - from_ts: (model.created_at - 12.hours).to_i * 1000, - to_ts: (model.created_at + 12.hours).to_i * 1000 - }.to_query}", - { target: :_blank, rel: :noopener } - ) - end - } - - field :tag, as: :text - field :rubygem, as: :belongs_to - field :ip_address, as: :belongs_to - field :geoip_info, as: :belongs_to - field :additional, as: :event_additional, show_on: :index -end diff --git a/app/avo/resources/events_user_event.rb b/app/avo/resources/events_user_event.rb new file mode 100644 index 00000000000..225c6fc723e --- /dev/null +++ b/app/avo/resources/events_user_event.rb @@ -0,0 +1,30 @@ +class Avo::Resources::EventsUserEvent < Avo::BaseResource + self.title = :cache_key + self.includes = %i[user ip_address geoip_info] + self.model_class = "Events::UserEvent" + + def fields + field :id, as: :id, hide_on: :index + + field :created_at, as: :date_time + field :trace_id, as: :text, format_using: proc { + if value.present? + link_to( + view == :index ? "🔗" : value, + "https://app.datadoghq.com/logs?query=#{{ + :@traceid => value, + from_ts: (record.created_at - 12.hours).to_i * 1000, + to_ts: (record.created_at + 12.hours).to_i * 1000 + }.to_query}", + { target: :_blank, rel: :noopener } + ) + end + } + + field :tag, as: :text + field :user, as: :belongs_to + field :ip_address, as: :belongs_to + field :geoip_info, as: :belongs_to + field :additional, as: :event_additional, show_on: :index + end +end diff --git a/app/avo/resources/events_user_event_resource.rb b/app/avo/resources/events_user_event_resource.rb deleted file mode 100644 index f1293d3fb1f..00000000000 --- a/app/avo/resources/events_user_event_resource.rb +++ /dev/null @@ -1,28 +0,0 @@ -class EventsUserEventResource < Avo::BaseResource - self.title = :cache_key - self.includes = %i[user ip_address geoip_info] - self.model_class = ::Events::UserEvent - - field :id, as: :id, hide_on: :index - - field :created_at, as: :date_time - field :trace_id, as: :text, format_using: proc { - if value.present? - link_to( - view == :index ? "🔗" : value, - "https://app.datadoghq.com/logs?query=#{{ - :@traceid => value, - from_ts: (model.created_at - 12.hours).to_i * 1000, - to_ts: (model.created_at + 12.hours).to_i * 1000 - }.to_query}", - { target: :_blank, rel: :noopener } - ) - end - } - - field :tag, as: :text - field :user, as: :belongs_to - field :ip_address, as: :belongs_to - field :geoip_info, as: :belongs_to - field :additional, as: :event_additional, show_on: :index -end diff --git a/app/avo/resources/gem_download.rb b/app/avo/resources/gem_download.rb new file mode 100644 index 00000000000..fd33f5963b5 --- /dev/null +++ b/app/avo/resources/gem_download.rb @@ -0,0 +1,33 @@ +class Avo::Resources::GemDownload < Avo::BaseResource + self.title = :inspect + self.includes = %i[rubygem version] + + self.index_query = lambda { + query.order(count: :desc) + } + + class SpecificityFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter SpecificityFilter, arguments: { default: { for_versions: true, for_rubygems: true, total: true } } + end + + def fields + field :title, as: :text, link_to_resource: true do |_model, _resource, _view| + if record.version + "#{record.version.full_name} (#{record.count.to_fs(:delimited)})" + elsif record.rubygem + "#{record.rubygem} (#{record.count.to_fs(:delimited)})" + else + "All Gems (#{record.count.to_fs(:delimited)})" + end + end + + field :rubygem, as: :belongs_to + field :version, as: :belongs_to + field :count, as: :number, sortable: true, html: { index: { wrapper: { classes: "text-right" } } }, + format_using: -> { value.to_fs(:delimited) }, default: 0 + + field :id, as: :id, hide_on: :index + end +end diff --git a/app/avo/resources/gem_download_resource.rb b/app/avo/resources/gem_download_resource.rb deleted file mode 100644 index 4cc08993f07..00000000000 --- a/app/avo/resources/gem_download_resource.rb +++ /dev/null @@ -1,27 +0,0 @@ -class GemDownloadResource < Avo::BaseResource - self.title = :inspect - self.includes = %i[rubygem version] - - self.resolve_query_scope = lambda { |model_class:| - model_class.order(count: :desc) - } - - class SpecificityFilter < ScopeBooleanFilter; end - filter SpecificityFilter, arguments: { default: { for_versions: true, for_rubygems: true, total: true } } - - field :title, as: :text, link_to_resource: true do |model, _resource, _view| - if model.version - "#{model.version.full_name} (#{model.count.to_fs(:delimited)})" - elsif model.rubygem - "#{model.rubygem} (#{model.count.to_fs(:delimited)})" - else - "All Gems (#{model.count.to_fs(:delimited)})" - end - end - - field :rubygem, as: :belongs_to - field :version, as: :belongs_to - field :count, as: :number, sortable: true, index_text_align: :right, format_using: -> { value.to_fs(:delimited) }, default: 0 - - field :id, as: :id, hide_on: :index -end diff --git a/app/avo/resources/gem_name_reservation.rb b/app/avo/resources/gem_name_reservation.rb new file mode 100644 index 00000000000..8676f15210e --- /dev/null +++ b/app/avo/resources/gem_name_reservation.rb @@ -0,0 +1,14 @@ +class Avo::Resources::GemNameReservation < Avo::BaseResource + self.title = :name + self.includes = [] + self.search = { + query: lambda { + query.where("name LIKE ?", "%#{params[:q]}%") + } + } + + def fields + field :id, as: :id + field :name, as: :text + end +end diff --git a/app/avo/resources/gem_name_reservation_resource.rb b/app/avo/resources/gem_name_reservation_resource.rb deleted file mode 100644 index 0b44fa49e59..00000000000 --- a/app/avo/resources/gem_name_reservation_resource.rb +++ /dev/null @@ -1,10 +0,0 @@ -class GemNameReservationResource < Avo::BaseResource - self.title = :name - self.includes = [] - self.search_query = lambda { - scope.where("name LIKE ?", "%#{params[:q]}%") - } - - field :id, as: :id - field :name, as: :text -end diff --git a/app/avo/resources/gem_typo_exception.rb b/app/avo/resources/gem_typo_exception.rb new file mode 100644 index 00000000000..ae5579df463 --- /dev/null +++ b/app/avo/resources/gem_typo_exception.rb @@ -0,0 +1,19 @@ +class Avo::Resources::GemTypoException < Avo::BaseResource + self.title = :name + self.includes = [] + self.search = { + query: lambda { + query.where("name ILIKE ?", "%#{params[:q]}%") + } + } + + def fields + field :id, as: :id, hide_on: :index + + field :name, as: :text, link_to_resource: true + field :info, as: :textarea + + field :created_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] + field :updated_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] + end +end diff --git a/app/avo/resources/gem_typo_exception_resource.rb b/app/avo/resources/gem_typo_exception_resource.rb deleted file mode 100644 index 8512e03d2f3..00000000000 --- a/app/avo/resources/gem_typo_exception_resource.rb +++ /dev/null @@ -1,15 +0,0 @@ -class GemTypoExceptionResource < Avo::BaseResource - self.title = :name - self.includes = [] - self.search_query = lambda { - scope.where("name ILIKE ?", "%#{params[:q]}%") - } - - field :id, as: :id, hide_on: :index - # Fields generated from the model - field :name, as: :text, link_to_resource: true - field :info, as: :textarea - # add fields here - field :created_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] - field :updated_at, as: :date_time, sortable: true, readonly: true, only_on: %i[index show] -end diff --git a/app/avo/resources/geoip_info.rb b/app/avo/resources/geoip_info.rb new file mode 100644 index 00000000000..ea4e35f35eb --- /dev/null +++ b/app/avo/resources/geoip_info.rb @@ -0,0 +1,14 @@ +class Avo::Resources::GeoipInfo < Avo::BaseResource + self.includes = [] + + def fields + field :continent_code, as: :text + field :country_code, as: :text + field :country_code3, as: :text + field :country_name, as: :text + field :region, as: :text + field :city, as: :text + + field :ip_addresses, as: :has_many + end +end diff --git a/app/avo/resources/geoip_info_resource.rb b/app/avo/resources/geoip_info_resource.rb deleted file mode 100644 index b049c7b965c..00000000000 --- a/app/avo/resources/geoip_info_resource.rb +++ /dev/null @@ -1,13 +0,0 @@ -class GeoipInfoResource < Avo::BaseResource - self.title = :id - self.includes = [] - - field :continent_code, as: :text - field :country_code, as: :text - field :country_code3, as: :text - field :country_name, as: :text - field :region, as: :text - field :city, as: :text - - field :ip_addresses, as: :has_many -end diff --git a/app/avo/resources/ip_address.rb b/app/avo/resources/ip_address.rb new file mode 100644 index 00000000000..a0e757efaf8 --- /dev/null +++ b/app/avo/resources/ip_address.rb @@ -0,0 +1,24 @@ +class Avo::Resources::IpAddress < Avo::BaseResource + self.title = :ip_address + self.includes = [] + + self.search = { + hide_on_global: true, + query: lambda { + query.where("ip_address <<= inet ?", params[:q]) + } + } + + def fields + field :id, as: :id + + field :ip_address, as: :text + field :hashed_ip_address, as: :textarea + field :geoip_info, as: :json_viewer + + tabs style: :pills do + field :user_events, as: :has_many + field :rubygem_events, as: :has_many + end + end +end diff --git a/app/avo/resources/ip_address_resource.rb b/app/avo/resources/ip_address_resource.rb deleted file mode 100644 index a6c99cc7271..00000000000 --- a/app/avo/resources/ip_address_resource.rb +++ /dev/null @@ -1,20 +0,0 @@ -class IpAddressResource < Avo::BaseResource - self.title = :ip_address - self.includes = [] - - self.hide_from_global_search = true - self.search_query = lambda { - scope.where("ip_address <<= inet ?", params[:q]) - } - - field :id, as: :id - - field :ip_address, as: :text - field :hashed_ip_address, as: :textarea - field :geoip_info, as: :json_viewer - - tabs style: :pills do - field :user_events, as: :has_many - field :rubygem_events, as: :has_many - end -end diff --git a/app/avo/resources/link_verification.rb b/app/avo/resources/link_verification.rb new file mode 100644 index 00000000000..60048673afe --- /dev/null +++ b/app/avo/resources/link_verification.rb @@ -0,0 +1,16 @@ +class Avo::Resources::LinkVerification < Avo::BaseResource + self.includes = [] + + def fields + field :id, as: :id + + field :linkable, as: :belongs_to, + polymorphic_as: :linkable, + types: [::Rubygem] + field :uri, as: :text + field :verified?, as: :boolean + field :last_verified_at, as: :date_time + field :last_failure_at, as: :date_time + field :failures_since_last_verification, as: :number + end +end diff --git a/app/avo/resources/link_verification_resource.rb b/app/avo/resources/link_verification_resource.rb deleted file mode 100644 index ee60c06d904..00000000000 --- a/app/avo/resources/link_verification_resource.rb +++ /dev/null @@ -1,19 +0,0 @@ -class LinkVerificationResource < Avo::BaseResource - self.title = :id - self.includes = [] - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - field :id, as: :id - # Fields generated from the model - field :linkable, as: :belongs_to, - polymorphic_as: :linkable, - types: %w[Rubygem] - field :uri, as: :text - field :verified?, as: :boolean - field :last_verified_at, as: :date_time - field :last_failure_at, as: :date_time - field :failures_since_last_verification, as: :number - # add fields here -end diff --git a/app/avo/resources/linkset.rb b/app/avo/resources/linkset.rb new file mode 100644 index 00000000000..aa642178821 --- /dev/null +++ b/app/avo/resources/linkset.rb @@ -0,0 +1,13 @@ +class Avo::Resources::Linkset < Avo::BaseResource + self.includes = [:rubygem] + self.visible_on_sidebar = false + + def fields + field :id, as: :id, link_to_resource: true + field :rubygem, as: :belongs_to + + Linkset::LINKS.each do |link| + field link, as: :text, format_using: -> { link_to value, value if value.present? } + end + end +end diff --git a/app/avo/resources/linkset_resource.rb b/app/avo/resources/linkset_resource.rb deleted file mode 100644 index ac5966a45bf..00000000000 --- a/app/avo/resources/linkset_resource.rb +++ /dev/null @@ -1,12 +0,0 @@ -class LinksetResource < Avo::BaseResource - self.title = :id - self.includes = [:rubygem] - self.visible_on_sidebar = false - - field :id, as: :id, link_to_resource: true - field :rubygem, as: :belongs_to - - Linkset::LINKS.each do |link| - field link, as: :text, format_using: -> { link_to value, value if value.present? } - end -end diff --git a/app/avo/resources/log_ticket.rb b/app/avo/resources/log_ticket.rb new file mode 100644 index 00000000000..1a16fbb13a1 --- /dev/null +++ b/app/avo/resources/log_ticket.rb @@ -0,0 +1,21 @@ +class Avo::Resources::LogTicket < Avo::BaseResource + self.includes = [] + + class BackendFilter < Avo::Filters::ScopeBooleanFilter; end + class StatusFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter BackendFilter, arguments: { default: LogTicket.backends.transform_values { true } } + filter StatusFilter, arguments: { default: LogTicket.statuses.transform_values { true } } + end + + def fields + field :id, as: :id, link_to_resource: true + + field :key, as: :text + field :directory, as: :text + field :backend, as: :select, enum: LogTicket.backends + field :status, as: :select, enum: LogTicket.statuses + field :processed_count, as: :number, sortable: true + end +end diff --git a/app/avo/resources/log_ticket_resource.rb b/app/avo/resources/log_ticket_resource.rb deleted file mode 100644 index 1a6c88419e2..00000000000 --- a/app/avo/resources/log_ticket_resource.rb +++ /dev/null @@ -1,18 +0,0 @@ -class LogTicketResource < Avo::BaseResource - self.title = :id - self.includes = [] - - class BackendFilter < ScopeBooleanFilter; end - filter BackendFilter, arguments: { default: LogTicket.backends.transform_values { true } } - - class StatusFilter < ScopeBooleanFilter; end - filter StatusFilter, arguments: { default: LogTicket.statuses.transform_values { true } } - - field :id, as: :id, link_to_resource: true - - field :key, as: :text - field :directory, as: :text - field :backend, as: :select, enum: LogTicket.backends - field :status, as: :select, enum: LogTicket.statuses - field :processed_count, as: :number, sortable: true -end diff --git a/app/avo/resources/maintenance_tasks_run.rb b/app/avo/resources/maintenance_tasks_run.rb new file mode 100644 index 00000000000..d1d7fb36f0a --- /dev/null +++ b/app/avo/resources/maintenance_tasks_run.rb @@ -0,0 +1,29 @@ +class Avo::Resources::MaintenanceTasksRun < Avo::BaseResource + self.includes = [] + self.model_class = ::MaintenanceTasks::Run + + class StatusFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter StatusFilter, arguments: { default: MaintenanceTasks::Run.statuses.transform_values { true } } + end + + def fields + field :id, as: :id + + field :task_name, as: :text + field :started_at, as: :date_time, sortable: true + field :ended_at, as: :date_time, sortable: true + field :time_running, as: :number, sortable: true + field :tick_count, as: :number + field :tick_total, as: :number + field :job_id, as: :text + field :cursor, as: :number + field :status, as: :select, enum: MaintenanceTasks::Run.statuses + field :error_class, as: :text + field :error_message, as: :text + field :backtrace, as: :textarea + field :arguments, as: :textarea + field :lock_version, as: :number + end +end diff --git a/app/avo/resources/maintenance_tasks_run_resource.rb b/app/avo/resources/maintenance_tasks_run_resource.rb deleted file mode 100644 index 0f553fdb3a5..00000000000 --- a/app/avo/resources/maintenance_tasks_run_resource.rb +++ /dev/null @@ -1,29 +0,0 @@ -class MaintenanceTasksRunResource < Avo::BaseResource - self.title = :id - self.includes = [] - self.model_class = ::MaintenanceTasks::Run - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - class StatusFilter < ScopeBooleanFilter; end - filter StatusFilter, arguments: { default: MaintenanceTasks::Run.statuses.transform_values { true } } - - field :id, as: :id - # Fields generated from the model - field :task_name, as: :text - field :started_at, as: :date_time, sortable: true - field :ended_at, as: :date_time, sortable: true - field :time_running, as: :number, sortable: true - field :tick_count, as: :number - field :tick_total, as: :number - field :job_id, as: :text - field :cursor, as: :number - field :status, as: :select, enum: MaintenanceTasks::Run.statuses - field :error_class, as: :text - field :error_message, as: :text - field :backtrace, as: :textarea - field :arguments, as: :textarea - field :lock_version, as: :number - # add fields here -end diff --git a/app/avo/resources/membership.rb b/app/avo/resources/membership.rb new file mode 100644 index 00000000000..006dbd889bf --- /dev/null +++ b/app/avo/resources/membership.rb @@ -0,0 +1,15 @@ +class Avo::Resources::Membership < Avo::BaseResource + self.includes = [] + + class ConfirmedFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter ConfirmedFilter, arguments: { default: { confirmed: true, unconfirmed: false } } + end + + def fields + field :id, as: :id + field :user, as: :belongs_to + field :organization, as: :belongs_to + end +end diff --git a/app/avo/resources/oidc_api_key_role.rb b/app/avo/resources/oidc_api_key_role.rb new file mode 100644 index 00000000000..8dc81b51b6a --- /dev/null +++ b/app/avo/resources/oidc_api_key_role.rb @@ -0,0 +1,34 @@ +class Avo::Resources::OIDCApiKeyRole < Avo::BaseResource + self.title = :token + self.includes = [] + self.model_class = ::OIDC::ApiKeyRole + + def fields + field :token, as: :text, link_to_resource: true, readonly: true + field :id, as: :id, link_to_resource: true, hide_on: :index + # Fields generated from the model + field :name, as: :text + field :provider, as: :belongs_to + field :user, as: :belongs_to, searchable: true + field :api_key_permissions, as: :nested do + field :valid_for, as: :text, format_using: -> { value&.iso8601 } + field :scopes, as: :tags, suggestions: ApiKey::API_SCOPES.map { { label: _1, value: _1 } }, enforce_suggestions: true + field :gems, as: :tags, suggestions: -> { Rubygem.limit(10).pluck(:name).map { { value: _1, label: _1 } } } + end + field :access_policy, as: :nested do + field :statements, as: :array_of, field: :nested do + field :effect, as: :select, options: { "Allow" => "allow" }, default: "Allow" + field :principal, as: :nested, field_options: { stacked: false } do + field :oidc, as: :text + end + field :conditions, as: :array_of, field: :nested, field_options: { stacked: false } do + field :operator, as: :select, options: OIDC::AccessPolicy::Statement::Condition::OPERATORS.index_by(&:titleize) + field :claim, as: :text + field :value, as: :text + end + end + end + + field :id_tokens, as: :has_many + end +end diff --git a/app/avo/resources/oidc_api_key_role_resource.rb b/app/avo/resources/oidc_api_key_role_resource.rb deleted file mode 100644 index 3f59087e912..00000000000 --- a/app/avo/resources/oidc_api_key_role_resource.rb +++ /dev/null @@ -1,36 +0,0 @@ -class OIDCApiKeyRoleResource < Avo::BaseResource - self.title = :token - self.includes = [] - self.model_class = ::OIDC::ApiKeyRole - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - field :token, as: :text, link_to_resource: true, readonly: true - field :id, as: :id, link_to_resource: true, hide_on: :index - # Fields generated from the model - field :name, as: :text - field :provider, as: :belongs_to - field :user, as: :belongs_to, searchable: true - field :api_key_permissions, as: :nested do - field :valid_for, as: :text, format_using: -> { value&.iso8601 } - field :scopes, as: :tags, suggestions: ApiKey::API_SCOPES.map { { label: _1, value: _1 } }, enforce_suggestions: true - field :gems, as: :tags, suggestions: -> { Rubygem.limit(10).pluck(:name).map { { value: _1, label: _1 } } } - end - field :access_policy, as: :nested do - field :statements, as: :array_of, field: :nested do - field :effect, as: :select, options: { "Allow" => "allow" }, default: "Allow" - field :principal, as: :nested, field_options: { stacked: false } do - field :oidc, as: :text - end - field :conditions, as: :array_of, field: :nested, field_options: { stacked: false } do - field :operator, as: :select, options: OIDC::AccessPolicy::Statement::Condition::OPERATORS.index_by(&:titleize) - field :claim, as: :text - field :value, as: :text - end - end - end - - field :id_tokens, as: :has_many - # add fields here -end diff --git a/app/avo/resources/oidc_id_token.rb b/app/avo/resources/oidc_id_token.rb new file mode 100644 index 00000000000..fc0c4a36462 --- /dev/null +++ b/app/avo/resources/oidc_id_token.rb @@ -0,0 +1,20 @@ +class Avo::Resources::OIDCIdToken < Avo::BaseResource + self.includes = [] + self.model_class = ::OIDC::IdToken + + def fields + field :id, as: :id + # Fields generated from the model + field :api_key_role, as: :belongs_to + field :provider, as: :has_one + field :api_key, as: :has_one + + field :jwt, as: :heading + field :claims, as: :key_value, stacked: true do + record.jwt.fetch("claims") + end + field :header, as: :key_value, stacked: true do + record.jwt.fetch("header") + end + end +end diff --git a/app/avo/resources/oidc_id_token_resource.rb b/app/avo/resources/oidc_id_token_resource.rb deleted file mode 100644 index bda90ed7025..00000000000 --- a/app/avo/resources/oidc_id_token_resource.rb +++ /dev/null @@ -1,23 +0,0 @@ -class OIDCIdTokenResource < Avo::BaseResource - self.title = :id - self.includes = [] - self.model_class = ::OIDC::IdToken - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - field :id, as: :id - # Fields generated from the model - field :api_key_role, as: :belongs_to - field :provider, as: :has_one - field :api_key, as: :has_one - - heading "JWT" - field :claims, as: :key_value, stacked: true do - model.jwt.fetch("claims") - end - field :header, as: :key_value, stacked: true do - model.jwt.fetch("header") - end - # add fields here -end diff --git a/app/avo/resources/oidc_pending_trusted_publisher.rb b/app/avo/resources/oidc_pending_trusted_publisher.rb new file mode 100644 index 00000000000..0e83788d396 --- /dev/null +++ b/app/avo/resources/oidc_pending_trusted_publisher.rb @@ -0,0 +1,19 @@ +class Avo::Resources::OIDCPendingTrustedPublisher < Avo::BaseResource + self.includes = [] + self.model_class = ::OIDC::PendingTrustedPublisher + + class ExpiredFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } + end + + def fields + field :id, as: :id + + field :rubygem_name, as: :text + field :user, as: :belongs_to + field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher + field :expires_at, as: :date_time + end +end diff --git a/app/avo/resources/oidc_pending_trusted_publisher_resource.rb b/app/avo/resources/oidc_pending_trusted_publisher_resource.rb deleted file mode 100644 index f546d3a8df1..00000000000 --- a/app/avo/resources/oidc_pending_trusted_publisher_resource.rb +++ /dev/null @@ -1,16 +0,0 @@ -class OIDCPendingTrustedPublisherResource < Avo::BaseResource - self.title = :id - self.includes = [] - self.model_class = ::OIDC::PendingTrustedPublisher - - class ExpiredFilter < ScopeBooleanFilter; end - filter ExpiredFilter, arguments: { default: { expired: false, unexpired: true } } - - field :id, as: :id - # Fields generated from the model - field :rubygem_name, as: :text - field :user, as: :belongs_to - field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher - field :expires_at, as: :date_time - # add fields here -end diff --git a/app/avo/resources/oidc_provider.rb b/app/avo/resources/oidc_provider.rb new file mode 100644 index 00000000000..12a06658e1e --- /dev/null +++ b/app/avo/resources/oidc_provider.rb @@ -0,0 +1,24 @@ +class Avo::Resources::OIDCProvider < Avo::BaseResource + self.title = :issuer + self.includes = [] + self.model_class = ::OIDC::Provider + + def actions + action Avo::Actions::RefreshOIDCProvider + end + + def fields + field :issuer, as: :text, link_to_resource: true + field :configuration, as: :nested do + visible_on = %i[edit new] + OIDC::Provider::Configuration.then { _1.required_attributes + _1.optional_attributes }.each do |k| + field k, as: (k.to_s.end_with?("s_supported") ? :tags : :text), + visible: -> { resource && (visible_on.include?(resource.view) || resource.record.configuration&.send(k).present?) } + end + end + field :jwks, as: :array_of, field: :json_viewer, hide_on: :index + field :api_key_roles, as: :has_many + + field :id, as: :id + end +end diff --git a/app/avo/resources/oidc_provider_resource.rb b/app/avo/resources/oidc_provider_resource.rb deleted file mode 100644 index 8a2fa0fe6d4..00000000000 --- a/app/avo/resources/oidc_provider_resource.rb +++ /dev/null @@ -1,20 +0,0 @@ -class OIDCProviderResource < Avo::BaseResource - self.title = :issuer - self.includes = [] - self.model_class = ::OIDC::Provider - - action RefreshOIDCProvider - - # Fields generated from the model - field :issuer, as: :text, link_to_resource: true - field :configuration, as: :nested do - visible_on = %i[edit new] - OIDC::Provider::Configuration.then { (_1.required_attributes + _1.optional_attributes) - fields.map(&:id) }.each do |k| - field k, as: (k.to_s.end_with?("s_supported") ? :tags : :text), visible: ->(_) { visible_on.include?(view) || value.send(k).present? } - end - end - field :jwks, as: :array_of, field: :json_viewer, hide_on: :index - field :api_key_roles, as: :has_many - # add fields here - field :id, as: :id -end diff --git a/app/avo/resources/oidc_rubygem_trusted_publisher.rb b/app/avo/resources/oidc_rubygem_trusted_publisher.rb new file mode 100644 index 00000000000..95e165d6d88 --- /dev/null +++ b/app/avo/resources/oidc_rubygem_trusted_publisher.rb @@ -0,0 +1,11 @@ +class Avo::Resources::OIDCRubygemTrustedPublisher < Avo::BaseResource + self.includes = [:trusted_publisher] + self.model_class = ::OIDC::RubygemTrustedPublisher + + def fields + field :id, as: :id + + field :rubygem, as: :belongs_to + field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher + end +end diff --git a/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb b/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb deleted file mode 100644 index 96c63c43096..00000000000 --- a/app/avo/resources/oidc_rubygem_trusted_publisher_resource.rb +++ /dev/null @@ -1,11 +0,0 @@ -class OIDCRubygemTrustedPublisherResource < Avo::BaseResource - self.title = :id - self.includes = [:trusted_publisher] - self.model_class = ::OIDC::RubygemTrustedPublisher - - field :id, as: :id - # Fields generated from the model - field :rubygem, as: :belongs_to - field :trusted_publisher, as: :belongs_to, polymorphic_as: :trusted_publisher - # add fields here -end diff --git a/app/avo/resources/oidc_trusted_publisher_github_action.rb b/app/avo/resources/oidc_trusted_publisher_github_action.rb new file mode 100644 index 00000000000..0a850ed2e21 --- /dev/null +++ b/app/avo/resources/oidc_trusted_publisher_github_action.rb @@ -0,0 +1,20 @@ +class Avo::Resources::OIDCTrustedPublisherGitHubAction < Avo::BaseResource + self.title = :name + self.includes = [] + self.model_class = ::OIDC::TrustedPublisher::GitHubAction + + def fields + field :id, as: :id + + field :repository_owner, as: :text + field :repository_name, as: :text + field :repository_owner_id, as: :text + field :workflow_filename, as: :text + field :environment, as: :text + + field :rubygem_trusted_publishers, as: :has_many + field :pending_trusted_publishers, as: :has_many + field :rubygems, as: :has_many, through: :rubygem_trusted_publishers + field :api_keys, as: :has_many, inverse_of: :owner + end +end diff --git a/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb b/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb deleted file mode 100644 index 0ea00fd83e4..00000000000 --- a/app/avo/resources/oidc_trusted_publisher_github_action_resource.rb +++ /dev/null @@ -1,19 +0,0 @@ -class OIDCTrustedPublisherGitHubActionResource < Avo::BaseResource - self.title = :name - self.includes = [] - self.model_class = ::OIDC::TrustedPublisher::GitHubAction - - field :id, as: :id - # Fields generated from the model - field :repository_owner, as: :text - field :repository_name, as: :text - field :repository_owner_id, as: :text - field :workflow_filename, as: :text - field :environment, as: :text - # add fields here - # - field :rubygem_trusted_publishers, as: :has_many - field :pending_trusted_publishers, as: :has_many - field :rubygems, as: :has_many, through: :rubygem_trusted_publishers - field :api_keys, as: :has_many, inverse_of: :owner -end diff --git a/app/avo/resources/organization.rb b/app/avo/resources/organization.rb new file mode 100644 index 00000000000..b70f952cf09 --- /dev/null +++ b/app/avo/resources/organization.rb @@ -0,0 +1,35 @@ +class Avo::Resources::Organization < Avo::BaseResource + self.title = :name + self.includes = [] + self.search = { + query: lambda { + query.where("name LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") + } + } + + self.find_record_method = lambda { + query.find_by_handle!(id) + } + + class DeletedFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter DeletedFilter, arguments: { default: { not_deleted: true, deleted: false } } + end + + def fields + field :id, as: :id + # Fields generated from the model + field :handle, as: :text + field :name, as: :text + field :deleted_at, as: :date_time + # add fields here + tabs style: :pills do + field :memberships, as: :has_many + field :unconfirmed_memberships, as: :has_many + field :users, as: :has_many + field :rubygems, as: :has_many + field :organization_onboarding, as: :belongs_to + end + end +end diff --git a/app/avo/resources/organization_onboarding.rb b/app/avo/resources/organization_onboarding.rb new file mode 100644 index 00000000000..ed6dcb4313f --- /dev/null +++ b/app/avo/resources/organization_onboarding.rb @@ -0,0 +1,27 @@ +class Avo::Resources::OrganizationOnboarding < Avo::BaseResource + self.title = :organization_name + self.includes = [:invites] + + def actions + action Avo::Actions::OnboardOrganization + end + + def fields + field :id, as: :id + field :status, as: :select, enum: OrganizationOnboarding.statuses + field :organization_name, as: :text + field :organization_handle, as: :text + field :created_by, as: :belongs_to + field :error, as: :text + + field :onboarded_at, as: :date_time + field :created_at, as: :date_time + field :updated_at, as: :date_time + + tabs style: :pills do + field :users, as: :has_many, through: :invites + field :invites, as: :has_many, use_resource: Avo::Resources::OrganizationOnboardingInvite + field :organization, as: :has_one + end + end +end diff --git a/app/avo/resources/organization_onboarding_invite.rb b/app/avo/resources/organization_onboarding_invite.rb new file mode 100644 index 00000000000..001269d7c79 --- /dev/null +++ b/app/avo/resources/organization_onboarding_invite.rb @@ -0,0 +1,11 @@ +class Avo::Resources::OrganizationOnboardingInvite < Avo::BaseResource + self.title = :id + self.includes = [:organization_onboarding] + + def fields + field :id, as: :id + field :organization_onboarding, as: :belongs_to + field :user, as: :belongs_to + field :role, as: :select, enum: OrganizationOnboardingInvite.roles + end +end diff --git a/app/avo/resources/ownership.rb b/app/avo/resources/ownership.rb new file mode 100644 index 00000000000..079445d9d63 --- /dev/null +++ b/app/avo/resources/ownership.rb @@ -0,0 +1,36 @@ +class Avo::Resources::Ownership < Avo::BaseResource + self.title = :cache_key + self.includes = [] + + class ConfirmedFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter ConfirmedFilter, arguments: { default: { confirmed: true, unconfirmed: true } } + end + + def fields + field :id, as: :id, link_to_resource: true + + field :user, as: :belongs_to + field :rubygem, as: :belongs_to + + field :role, as: :select, enum: Ownership.roles + + field :token, as: :heading + + field :token, as: :text, visible: -> { false } + field :token_expires_at, as: :date_time + field :api_key_rubygem_scopes, as: :has_many + + field :notifications, as: :heading + + field :push_notifier, as: :boolean + field :owner_notifier, as: :boolean + field :ownership_request_notifier, as: :boolean + + field :authorization, as: :heading + + field :authorizer, as: :belongs_to + field :confirmed_at, as: :date_time + end +end diff --git a/app/avo/resources/ownership_resource.rb b/app/avo/resources/ownership_resource.rb deleted file mode 100644 index cd51054bbd0..00000000000 --- a/app/avo/resources/ownership_resource.rb +++ /dev/null @@ -1,29 +0,0 @@ -class OwnershipResource < Avo::BaseResource - self.title = :cache_key - self.includes = [] - - class ConfirmedFilter < ScopeBooleanFilter; end - filter ConfirmedFilter, arguments: { default: { confirmed: true, unconfirmed: true } } - - field :id, as: :id, link_to_resource: true - - field :user, as: :belongs_to - field :rubygem, as: :belongs_to - - heading "Token" - - field :token, as: :text, visible: ->(_) { false } - field :token_expires_at, as: :date_time - field :api_key_rubygem_scopes, as: :has_many - - heading "Notifications" - - field :push_notifier, as: :boolean - field :owner_notifier, as: :boolean - field :ownership_request_notifier, as: :boolean - - heading "Authorization" - - field :authorizer, as: :belongs_to - field :confirmed_at, as: :date_time -end diff --git a/app/avo/resources/rubygem.rb b/app/avo/resources/rubygem.rb new file mode 100644 index 00000000000..4f3c81c1698 --- /dev/null +++ b/app/avo/resources/rubygem.rb @@ -0,0 +1,56 @@ +class Avo::Resources::Rubygem < Avo::BaseResource + self.title = :name + self.includes = [] + self.search = { + query: lambda { + query.where("name LIKE ?", "%#{params[:q]}%") + } + } + + def actions + action Avo::Actions::ReleaseReservedNamespace + action Avo::Actions::AddOwner + action Avo::Actions::YankRubygem + action Avo::Actions::UploadInfoFile + action Avo::Actions::UploadNamesFile + action Avo::Actions::UploadVersionsFile + end + + class IndexedFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter IndexedFilter, arguments: { default: { with_versions: true, without_versions: true } } + end + + def fields + field :name, as: :text, link_to_resource: true + field :indexed, as: :boolean + field :slug, as: :text, hide_on: :index + field :id, as: :id, hide_on: :index + field :protected_days, as: :number, hide_on: :index + + tabs style: :pills do + field :versions, as: :has_many + field :latest_version, as: :has_one + + field :ownerships, as: :has_many + field :ownerships_including_unconfirmed, as: :has_many + field :ownership_calls, as: :has_many + field :ownership_requests, as: :has_many + field :organization, as: :belongs_to + + field :subscriptions, as: :has_many + field :subscribers, as: :has_many, through: :subscriptions + + field :web_hooks, as: :has_many + field :linkset, as: :has_one + field :gem_download, as: :has_one + + field :link_verifications, as: :has_many + field :oidc_rubygem_trusted_publishers, as: :has_many + + field :audits, as: :has_many + field :events, as: :has_many + end + end +end diff --git a/app/avo/resources/rubygem_resource.rb b/app/avo/resources/rubygem_resource.rb deleted file mode 100644 index 09636f782de..00000000000 --- a/app/avo/resources/rubygem_resource.rb +++ /dev/null @@ -1,47 +0,0 @@ -class RubygemResource < Avo::BaseResource - self.title = :name - self.includes = [] - self.search_query = lambda { - scope.where("name LIKE ?", "%#{params[:q]}%") - } - - action ReleaseReservedNamespace - action AddOwner - action YankRubygem - action UploadInfoFile - action UploadNamesFile - action UploadVersionsFile - - class IndexedFilter < ScopeBooleanFilter; end - filter IndexedFilter, arguments: { default: { with_versions: true, without_versions: true } } - - # Fields generated from the model - field :name, as: :text, link_to_resource: true - field :indexed, as: :boolean - field :slug, as: :text, hide_on: :index - field :id, as: :id, hide_on: :index - field :protected_days, as: :number, hide_on: :index - - tabs style: :pills do - field :versions, as: :has_many - field :latest_version, as: :has_one - - field :ownerships, as: :has_many - field :ownerships_including_unconfirmed, as: :has_many - field :ownership_calls, as: :has_many - field :ownership_requests, as: :has_many - - field :subscriptions, as: :has_many - field :subscribers, as: :has_many, through: :subscriptions - - field :web_hooks, as: :has_many - field :linkset, as: :has_one - field :gem_download, as: :has_one - - field :link_verifications, as: :has_many - field :oidc_rubygem_trusted_publishers, as: :has_many - - field :events, as: :has_many - field :audits, as: :has_many - end -end diff --git a/app/avo/resources/sendgrid_event.rb b/app/avo/resources/sendgrid_event.rb new file mode 100644 index 00000000000..c9d57d8a636 --- /dev/null +++ b/app/avo/resources/sendgrid_event.rb @@ -0,0 +1,27 @@ +class Avo::Resources::SendgridEvent < Avo::BaseResource + self.title = :sendgrid_id + self.includes = [] + # self.search_query = -> do + # query.ransack(id_eq: params[:q], m: "or").result(distinct: false) + # end + + class StatusFilter < Avo::Filters::ScopeBooleanFilter; end + class EventTypeFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter StatusFilter, arguments: { default: SendgridEvent.statuses.transform_values { true } } + filter EventTypeFilter, arguments: { default: SendgridEvent.event_types.transform_values { true } } + filter Avo::Filters::EmailFilter + end + + def fields + field :id, as: :id, hide_on: :index + + field :sendgrid_id, as: :text, link_to_resource: true + field :email, as: :text + field :event_type, as: :text + field :occurred_at, as: :date_time, sortable: true + field :payload, as: :json_viewer + field :status, as: :select, enum: SendgridEvent.statuses + end +end diff --git a/app/avo/resources/sendgrid_event_resource.rb b/app/avo/resources/sendgrid_event_resource.rb deleted file mode 100644 index 9328ed102db..00000000000 --- a/app/avo/resources/sendgrid_event_resource.rb +++ /dev/null @@ -1,25 +0,0 @@ -class SendgridEventResource < Avo::BaseResource - self.title = :sendgrid_id - self.includes = [] - # self.search_query = -> do - # scope.ransack(id_eq: params[:q], m: "or").result(distinct: false) - # end - - class StatusFilter < ScopeBooleanFilter; end - filter StatusFilter, arguments: { default: SendgridEvent.statuses.transform_values { true } } - - class EventTypeFilter < ScopeBooleanFilter; end - filter EventTypeFilter, arguments: { default: SendgridEvent.event_types.transform_values { true } } - - filter EmailFilter - - field :id, as: :id, hide_on: :index - # Fields generated from the model - field :sendgrid_id, as: :text, link_to_resource: true - field :email, as: :text - field :event_type, as: :text - field :occurred_at, as: :date_time, sortable: true - field :payload, as: :json_viewer - field :status, as: :select, enum: SendgridEvent.statuses - # add fields here -end diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb new file mode 100644 index 00000000000..2e594b52c06 --- /dev/null +++ b/app/avo/resources/user.rb @@ -0,0 +1,77 @@ +class Avo::Resources::User < Avo::BaseResource + self.title = :name + self.includes = [] + self.search = { + query: lambda { + query.where("email LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") + } + } + + def actions + action Avo::Actions::BlockUser + action Avo::Actions::CreateUser + action Avo::Actions::ChangeUserEmail + action Avo::Actions::ResetApiKey + action Avo::Actions::ResetUser2fa + action Avo::Actions::YankRubygemsForUser + action Avo::Actions::YankUser + end + + def fields # rubocop:disable Metrics + field :id, as: :id + + field :email, as: :text + field :gravatar, + as: :gravatar, + rounded: true, + size: 48 do |_, _, _| + record.email + end + + field :email_confirmed, as: :boolean + + field :email_reset, as: :boolean + field :handle, as: :text + field :public_email, as: :boolean + field :twitter_username, as: :text, as_html: true, format_using: -> { link_to value, "https://twitter.com/#{value}", target: :_blank, rel: :noopener if value.present? } + field :unconfirmed_email, as: :text + + field :mail_fails, as: :number + field :blocked_email, as: :text + + tabs style: :pills do + tab "Auth" do + field :encrypted_password, as: :password, visible: -> { false } + field :totp_seed, as: :text, visible: -> { false } + field :mfa_seed, as: :text, visible: -> { false } # legacy field + field :mfa_level, as: :select, enum: ::User.mfa_levels + field :mfa_recovery_codes, as: :text, visible: -> { false } + field :mfa_hashed_recovery_codes, as: :text, visible: -> { false } + field :webauthn_id, as: :text + field :remember_token_expires_at, as: :date_time + field :api_key, as: :text, visible: -> { false } + field :confirmation_token, as: :text, visible: -> { false } + field :remember_token, as: :text, visible: -> { false } + field :salt, as: :text, visible: -> { false } + field :token, as: :text, visible: -> { false } + field :token_expires_at, as: :date_time + end + field :ownerships, as: :has_many + field :rubygems, as: :has_many, through: :ownerships + field :subscriptions, as: :has_many + field :subscribed_gems, as: :has_many, through: :subscriptions + field :deletions, as: :has_many + field :web_hooks, as: :has_many + field :unconfirmed_ownerships, as: :has_many + field :api_keys, as: :has_many, name: "API Keys" + field :ownership_calls, as: :has_many + field :ownership_requests, as: :has_many + field :pushed_versions, as: :has_many + field :oidc_api_key_roles, as: :has_many + field :webauthn_credentials, as: :has_many + field :webauthn_verification, as: :has_one + + field :audits, as: :has_many + end + end +end diff --git a/app/avo/resources/user_resource.rb b/app/avo/resources/user_resource.rb deleted file mode 100644 index f8e28066d22..00000000000 --- a/app/avo/resources/user_resource.rb +++ /dev/null @@ -1,78 +0,0 @@ -class UserResource < Avo::BaseResource - self.title = :name - self.includes = [] - self.search_query = lambda { - scope.where("email LIKE ? OR handle LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") - } - self.unscoped_queries_on_index = true - - class DeletedFilter < ScopeBooleanFilter; end - filter DeletedFilter, arguments: { default: { not_deleted: true, deleted: false } } - - action BlockUser - action CreateUser - action ChangeUserEmail - action ResetApiKey - action ResetUser2fa - action YankRubygemsForUser - action YankUser - - field :id, as: :id - # Fields generated from the model - field :email, as: :text - field :gravatar, - as: :gravatar, - rounded: true, - size: 48 do |_, _, _| - model.email - end - - field :email_confirmed, as: :boolean - - field :email_reset, as: :boolean - field :handle, as: :text - field :public_email, as: :boolean - field :twitter_username, as: :text, as_html: true, format_using: -> { link_to value, "https://twitter.com/#{value}", target: :_blank, rel: :noopener if value.present? } - field :unconfirmed_email, as: :text - - field :mail_fails, as: :number - field :blocked_email, as: :text - - field :deleted_at, as: :date_time - - tabs style: :pills do - tab "Auth" do - field :encrypted_password, as: :password, visible: ->(_) { false } - field :totp_seed, as: :text, visible: ->(_) { false } - field :mfa_seed, as: :text, visible: ->(_) { false } # legacy field - field :mfa_level, as: :select, enum: ::User.mfa_levels - field :mfa_recovery_codes, as: :text, visible: ->(_) { false } - field :mfa_hashed_recovery_codes, as: :text, visible: ->(_) { false } - field :webauthn_id, as: :text - field :remember_token_expires_at, as: :date_time - field :api_key, as: :text, visible: ->(_) { false } - field :confirmation_token, as: :text, visible: ->(_) { false } - field :remember_token, as: :text, visible: ->(_) { false } - field :salt, as: :text, visible: ->(_) { false } - field :token, as: :text, visible: ->(_) { false } - field :token_expires_at, as: :date_time - end - field :ownerships, as: :has_many - field :rubygems, as: :has_many, through: :ownerships - field :subscriptions, as: :has_many - field :subscribed_gems, as: :has_many, through: :subscriptions - field :deletions, as: :has_many - field :web_hooks, as: :has_many - field :unconfirmed_ownerships, as: :has_many - field :api_keys, as: :has_many, name: "API Keys" - field :ownership_calls, as: :has_many - field :ownership_requests, as: :has_many - field :pushed_versions, as: :has_many - field :oidc_api_key_roles, as: :has_many - field :webauthn_credentials, as: :has_many - field :webauthn_verification, as: :has_one - - field :audits, as: :has_many - field :events, as: :has_many - end -end diff --git a/app/avo/resources/version.rb b/app/avo/resources/version.rb new file mode 100644 index 00000000000..4e3e14de176 --- /dev/null +++ b/app/avo/resources/version.rb @@ -0,0 +1,79 @@ +class Avo::Resources::Version < Avo::BaseResource + self.title = :full_name + self.includes = [:rubygem] + self.search = { + query: lambda { + query.where("full_name LIKE ?", "#{params[:q]}%") + } + } + + def actions + action Avo::Actions::RestoreVersion + action Avo::Actions::VersionAfterWrite + end + + class IndexedFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter IndexedFilter, arguments: { default: { indexed: true, yanked: true } } + end + + def fields # rubocop:disable Metrics + field :full_name, as: :text, link_to_resource: true + field :id, as: :id, hide_on: :index, as_html: true do |_id, *_args| + link_to record.id, main_app.rubygem_version_url(record.rubygem.slug, record.slug) + end + + field :rubygem, as: :belongs_to + field :slug, as: :text, hide_on: :index + field :number, as: :text + field :platform, as: :text + + field :canonical_number, as: :text + + field :indexed, as: :boolean + field :prerelease, as: :boolean + field :position, as: :number + field :latest, as: :boolean + + field :yanked_at, as: :date_time, sortable: true + + field :pusher, as: :belongs_to, class: "User" + field :pusher_api_key, as: :belongs_to, class: "ApiKey" + + tabs do + tab "Metadata", description: "Metadata that comes from the gemspec" do + panel do + field :summary, as: :textarea + field :description, as: :textarea + field :authors, as: :textarea + field :licenses, as: :textarea + field :cert_chain, as: :textarea + field :built_at, as: :date_time, sortable: true + field :metadata, as: :key_value, stacked: true + end + end + + tab "Runtime information" do + panel do + field :size, as: :number, sortable: true + field :requirements, as: :textarea + field :required_ruby_version, as: :text + field :sha256, as: :text + field :required_rubygems_version, as: :text + end + end + + tab "API" do + panel do + field :info_checksum, as: :text + field :yanked_info_checksum, as: :text + end + end + + field :dependencies, as: :has_many + field :gem_download, as: :has_one, name: "Downloads" + field :deletion, as: :has_one + end + end +end diff --git a/app/avo/resources/version_resource.rb b/app/avo/resources/version_resource.rb deleted file mode 100644 index 62d853fb69c..00000000000 --- a/app/avo/resources/version_resource.rb +++ /dev/null @@ -1,70 +0,0 @@ -class VersionResource < Avo::BaseResource - self.title = :full_name - self.includes = [:rubygem] - self.search_query = lambda { - scope.where("full_name LIKE ?", "#{params[:q]}%") - } - - action RestoreVersion - action VersionAfterWrite - - class IndexedFilter < ScopeBooleanFilter; end - filter IndexedFilter, arguments: { default: { indexed: true, yanked: true } } - - field :full_name, as: :text, link_to_resource: true - field :id, as: :id, hide_on: :index, as_html: true do |_id, *_args| - link_to model.id, main_app.rubygem_version_url(model.rubygem.slug, model.slug) - end - - field :rubygem, as: :belongs_to - field :slug, as: :text, hide_on: :index - field :number, as: :text - field :platform, as: :text - - field :canonical_number, as: :text - - field :indexed, as: :boolean - field :prerelease, as: :boolean - field :position, as: :number - field :latest, as: :boolean - - field :yanked_at, as: :date_time, sortable: true - - field :pusher, as: :belongs_to, class: "User" - field :pusher_api_key, as: :belongs_to, class: "ApiKey" - - tabs do - tab "Metadata", description: "Metadata that comes from the gemspec" do - panel do - field :summary, as: :textarea - field :description, as: :textarea - field :authors, as: :textarea - field :licenses, as: :textarea - field :cert_chain, as: :textarea - field :built_at, as: :date_time, sortable: true - field :metadata, as: :key_value, stacked: true - end - end - - tab "Runtime information" do - panel do - field :size, as: :number, sortable: true - field :requirements, as: :textarea - field :required_ruby_version, as: :text - field :sha256, as: :text - field :required_rubygems_version, as: :text - end - end - - tab "API" do - panel do - field :info_checksum, as: :text - field :yanked_info_checksum, as: :text - end - end - - field :dependencies, as: :has_many - field :gem_download, as: :has_one, name: "Downloads" - field :deletion, as: :has_one - end -end diff --git a/app/avo/resources/web_hook.rb b/app/avo/resources/web_hook.rb new file mode 100644 index 00000000000..786fdd4fac7 --- /dev/null +++ b/app/avo/resources/web_hook.rb @@ -0,0 +1,42 @@ +class Avo::Resources::WebHook < Avo::BaseResource + self.includes = %i[user rubygem] + + def actions + action Avo::Actions::DeleteWebhook + end + + class EnabledFilter < Avo::Filters::ScopeBooleanFilter; end + class GlobalFilter < Avo::Filters::ScopeBooleanFilter; end + + def filters + filter EnabledFilter, arguments: { default: { enabled: true, disabled: false } } + filter GlobalFilter, arguments: { default: { global: true, specific: true } } + end + + def fields + field :id, as: :id, link_to_resource: true + + field :url, as: :text + field :enabled?, as: :boolean + field :failure_count, as: :number, sortable: true, html: { index: { wrapper: { classes: "text-right" } } } + field :user, as: :belongs_to + field :rubygem, as: :belongs_to + field :global?, as: :boolean + + field :hook_relay_stream, as: :text do + stream_name = "webhook_id-#{record.id}" + link_to stream_name, "https://app.hookrelay.dev/hooks/#{ENV['HOOK_RELAY_HOOK_ID']}?started_at=P1W&stream=#{stream_name}" + end + + field :disabled_reason, as: :text + field :disabled_at, as: :date_time, sortable: true + field :last_success, as: :date_time, sortable: true + field :last_failure, as: :date_time, sortable: true + field :successes_since_last_failure, as: :number, sortable: true + field :failures_since_last_success, as: :number, sortable: true + + tabs style: :pills do + field :audits, as: :has_many + end + end +end diff --git a/app/avo/resources/web_hook_resource.rb b/app/avo/resources/web_hook_resource.rb deleted file mode 100644 index ee2c22d59f9..00000000000 --- a/app/avo/resources/web_hook_resource.rb +++ /dev/null @@ -1,35 +0,0 @@ -class WebHookResource < Avo::BaseResource - self.title = :id - self.includes = %i[user rubygem] - - action DeleteWebhook - class EnabledFilter < ScopeBooleanFilter; end - filter EnabledFilter, arguments: { default: { enabled: true, disabled: false } } - class GlobalFilter < ScopeBooleanFilter; end - filter GlobalFilter, arguments: { default: { global: true, specific: true } } - - field :id, as: :id, link_to_resource: true - - field :url, as: :text - field :enabled?, as: :boolean - field :failure_count, as: :number, sortable: true, index_text_align: :right - field :user, as: :belongs_to - field :rubygem, as: :belongs_to - field :global?, as: :boolean - - field :hook_relay_stream, as: :text do - stream_name = "webhook_id-#{model.id}" - link_to stream_name, "https://app.hookrelay.dev/hooks/#{ENV['HOOK_RELAY_STREAM_ID']}?started_at=P1W&stream=#{stream_name}" - end - - field :disabled_reason, as: :text - field :disabled_at, as: :date_time, sortable: true - field :last_success, as: :date_time, sortable: true - field :last_failure, as: :date_time, sortable: true - field :successes_since_last_failure, as: :number, sortable: true - field :failures_since_last_success, as: :number, sortable: true - - tabs style: :pills do - field :audits, as: :has_many - end -end diff --git a/app/avo/resources/webauthn_credential.rb b/app/avo/resources/webauthn_credential.rb new file mode 100644 index 00000000000..efbde57c3c8 --- /dev/null +++ b/app/avo/resources/webauthn_credential.rb @@ -0,0 +1,13 @@ +class Avo::Resources::WebauthnCredential < Avo::BaseResource + self.includes = [] + + def fields + field :id, as: :id + + field :external_id, as: :text + field :public_key, as: :text + field :nickname, as: :text + field :sign_count, as: :number + field :user, as: :belongs_to + end +end diff --git a/app/avo/resources/webauthn_credential_resource.rb b/app/avo/resources/webauthn_credential_resource.rb deleted file mode 100644 index b6711b63eab..00000000000 --- a/app/avo/resources/webauthn_credential_resource.rb +++ /dev/null @@ -1,13 +0,0 @@ -class WebauthnCredentialResource < Avo::BaseResource - self.title = :id - self.includes = [] - - field :id, as: :id - # Fields generated from the model - field :external_id, as: :text - field :public_key, as: :text - field :nickname, as: :text - field :sign_count, as: :number - field :user, as: :belongs_to - # add fields here -end diff --git a/app/avo/resources/webauthn_verification.rb b/app/avo/resources/webauthn_verification.rb new file mode 100644 index 00000000000..4d02a0409aa --- /dev/null +++ b/app/avo/resources/webauthn_verification.rb @@ -0,0 +1,13 @@ +class Avo::Resources::WebauthnVerification < Avo::BaseResource + self.includes = [] + + def fields + field :id, as: :id + + field :path_token, as: :text + field :path_token_expires_at, as: :date_time + field :otp, as: :text + field :otp_expires_at, as: :date_time + field :user, as: :belongs_to + end +end diff --git a/app/avo/resources/webauthn_verification_resource.rb b/app/avo/resources/webauthn_verification_resource.rb deleted file mode 100644 index 5834540ac89..00000000000 --- a/app/avo/resources/webauthn_verification_resource.rb +++ /dev/null @@ -1,13 +0,0 @@ -class WebauthnVerificationResource < Avo::BaseResource - self.title = :id - self.includes = [] - - field :id, as: :id - # Fields generated from the model - field :path_token, as: :text - field :path_token_expires_at, as: :date_time - field :otp, as: :text - field :otp_expires_at, as: :date_time - field :user, as: :belongs_to - # add fields here -end diff --git a/app/components/avo/audited_changes_record_diff/show_component.html.erb b/app/components/avo/audited_changes_record_diff/show_component.html.erb index 82c6d99e068..15bc48d4e8c 100644 --- a/app/components/avo/audited_changes_record_diff/show_component.html.erb +++ b/app/components/avo/audited_changes_record_diff/show_component.html.erb @@ -1,4 +1,4 @@ -<%= render Avo::PanelComponent.new(title: title_link, classes: %w[w-full]) do |c| %> +<%= render Avo::PanelComponent.new(title: title_link, classes: "w-full") do |c| %> <% c.with_body do %> <% next unless authorized? %> diff --git a/app/components/avo/audited_changes_record_diff/show_component.rb b/app/components/avo/audited_changes_record_diff/show_component.rb index e898fe4593f..d17539f237d 100644 --- a/app/components/avo/audited_changes_record_diff/show_component.rb +++ b/app/components/avo/audited_changes_record_diff/show_component.rb @@ -10,16 +10,23 @@ def initialize(gid:, changes:, unchanged:, view:, user:) @view = view global_id = GlobalID.parse(gid) - model = begin + resource_class = Avo.resource_manager.get_resource_by_model_class(global_id.model_class) + unless resource_class + logger.info "No avo resource class for #{global_id} found" + return + end + + record = begin global_id.find rescue ActiveRecord::RecordNotFound global_id.model_class.new(id: global_id.model_id) end - return unless (@resource = Avo::App.get_resource_by_model_name(global_id.model_class)) - @resource.hydrate(model:, user:, view:) + @resource = resource_class.new(record:, view:, user:).detect_fields - @old_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:first)), view:) - @new_resource = resource.dup.hydrate(model: resource.model_class.new(**unchanged, **changes.transform_values(&:last)), view:) + @old_resource = resource_class + .new(record: resource.model_class.new(**unchanged, **changes.transform_values(&:first)), view:, user:).detect_fields + @new_resource = resource_class + .new(record: resource.model_class.new(**unchanged, **changes.transform_values(&:last)), view:, user:).detect_fields end def render? @@ -29,7 +36,7 @@ def render? attr_reader :gid, :changes, :unchanged, :user, :resource, :old_resource, :new_resource, :view def sorted_fields - @resource.fields + @resource.only_fields .reject { _1.is_a?(Avo::Fields::HasBaseField) } .sort_by.with_index { |f, i| [changes.key?(f.id.to_s) ? -1 : 1, i] } end @@ -59,16 +66,16 @@ def each_field # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedC end def component_for_field(field, resource) - field = field.hydrate(model: resource.model, view:) + field = field.hydrate(resource:, record: resource.record, view:, user:) field.component_for_view(view).new(field:, resource:) end def authorized? - Pundit.policy!(user, [:admin, resource.model]).avo_show? + Pundit.policy!(user, [:admin, resource.record]).avo_show? end def title_link - link_to(resource.model_title, resource.record_path) + link_to(resource.record_title, resource.record_path) end def change_type_icon(type) diff --git a/app/components/avo/fields/audited_changes_field/show_component.html.erb b/app/components/avo/fields/audited_changes_field/show_component.html.erb index 9b4ee42a56e..20941303dd5 100644 --- a/app/components/avo/fields/audited_changes_field/show_component.html.erb +++ b/app/components/avo/fields/audited_changes_field/show_component.html.erb @@ -1,5 +1,6 @@ <%= field_wrapper **field_wrapper_args, full_width: true do %> <% records&.each do |gid, changes, unchanged| %> +

<%= gid %>

<%= render Avo::AuditedChangesRecordDiff::ShowComponent.new( gid:, changes:, diff --git a/app/controllers/adoptions_controller.rb b/app/controllers/adoptions_controller.rb index 77f4d09aebf..8ee0d1cbdf1 100644 --- a/app/controllers/adoptions_controller.rb +++ b/app/controllers/adoptions_controller.rb @@ -2,7 +2,7 @@ class AdoptionsController < ApplicationController include SessionVerifiable before_action :find_rubygem - before_action :redirect_to_verify, if: -> { @rubygem.owned_by?(current_user) && !verified_session_active? } + before_action :redirect_to_verify, if: -> { policy(@rubygem).manage_adoption? && !verified_session_active? } def index @ownership_call = @rubygem.ownership_call diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 46b886f3ae9..3eefc10e801 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -2,7 +2,9 @@ class Api::BaseController < ApplicationController skip_before_action :verify_authenticity_token after_action :skip_session - rescue_from(Pundit::NotAuthorizedError) { |_| render_api_key_forbidden } + rescue_from(Pundit::NotAuthorizedError) do |e| + render_forbidden(e.policy.error) + end private @@ -17,13 +19,13 @@ def gem_name def find_rubygem_by_name @rubygem = Rubygem.find_by name: gem_name return if @rubygem - render plain: "This gem could not be found", status: :not_found + render plain: t(:api_gem_not_found), status: :not_found end def verify_api_key_gem_scope return unless @api_key.rubygem && @api_key.rubygem != @rubygem - render plain: "This API key cannot perform the specified action on this gem.", status: :forbidden + render_forbidden t(:api_key_insufficient_scope) end def verify_with_otp @@ -33,58 +35,16 @@ def verify_with_otp render plain: prompt_text, status: :unauthorized end - def verify_mfa_requirement - if @rubygem && !@rubygem.mfa_requirement_satisfied_for?(@api_key.user) - render plain: "Gem requires MFA enabled; You do not have MFA enabled yet.", status: :forbidden - elsif @api_key.mfa_required_not_yet_enabled? - render_mfa_setup_required_error - elsif @api_key.mfa_required_weak_level_enabled? - render_mfa_strong_level_required_error + def response_with_mfa_warning(message) + if @api_key&.mfa_recommended_not_yet_enabled? + +message << "\n\n" << t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp + elsif @api_key&.mfa_recommended_weak_level_enabled? + +message << "\n\n" << t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp + else + message end end - def response_with_mfa_warning(response) - message = response - if @api_key.mfa_recommended_not_yet_enabled? - message += <<~WARN.chomp - - - [WARNING] For protection of your account and gems, we encourage you to set up multi-factor authentication \ - at https://rubygems.org/totp/new. Your account will be required to have MFA enabled in the future. - WARN - elsif @api_key.mfa_recommended_weak_level_enabled? - message += <<~WARN.chomp - - - [WARNING] For protection of your account and gems, we encourage you to change your multi-factor authentication \ - level to 'UI and gem signin' or 'UI and API' at https://rubygems.org/settings/edit. \ - Your account will be required to have MFA enabled on one of these levels in the future. - WARN - end - - message - end - - def render_mfa_setup_required_error - error = <<~ERROR.chomp - [ERROR] For protection of your account and your gems, you are required to set up multi-factor authentication \ - at https://rubygems.org/totp/new. - - Please read our blog post for more details (https://blog.rubygems.org/2022/08/15/requiring-mfa-on-popular-gems.html). - ERROR - render_forbidden(error) - end - - def render_mfa_strong_level_required_error - error = <<~ERROR.chomp - [ERROR] For protection of your account and your gems, you are required to change your MFA level to 'UI and gem signin' or 'UI and API' \ - at https://rubygems.org/settings/edit. - - Please read our blog post for more details (https://blog.rubygems.org/2022/08/15/requiring-mfa-on-popular-gems.html). - ERROR - render_forbidden(error) - end - def authenticate_with_api_key params_key = request.headers["Authorization"] || "" hashed_key = Digest::SHA256.hexdigest(params_key) @@ -92,7 +52,7 @@ def authenticate_with_api_key return render_unauthorized unless @api_key set_tags "gemcutter.api_key.owner" => @api_key.owner.to_gid, "gemcutter.user.api_key_id" => @api_key.id Current.user = @api_key.user - render_soft_deleted_api_key if @api_key.soft_deleted? + render_forbidden(t(:api_key_soft_deleted)) if @api_key.soft_deleted? end def pundit_user @@ -104,19 +64,21 @@ def policy_scope(scope) end def authorize(record, query = nil) + return if record.nil? # not found is handled by the action super(Array.wrap(record).prepend(:api), query) end def verify_user_api_key - render_api_key_forbidden if @api_key.user.blank? + render_forbidden(t(:api_key_forbidden)) if @api_key.user.blank? end def render_unauthorized render plain: t(:please_sign_up), status: :unauthorized end - def render_api_key_forbidden(error = nil) - error = error&.message || t(:api_key_forbidden) + def render_forbidden(error = nil) + error = error.message if error.respond_to?(:message) + error ||= t(:api_key_forbidden) respond_to do |format| format.any(:all) { render plain: error, status: :forbidden } format.json { render json: { error: }, status: :forbidden } @@ -124,10 +86,6 @@ def render_api_key_forbidden(error = nil) end end - def render_soft_deleted_api_key - render plain: "An invalid API key cannot be used. Please delete it and create a new one.", status: :forbidden - end - def skip_session request.session_options[:skip] = true end diff --git a/app/controllers/api/compact_index_controller.rb b/app/controllers/api/compact_index_controller.rb index 386e3cad3c1..bdae9f274cd 100644 --- a/app/controllers/api/compact_index_controller.rb +++ b/app/controllers/api/compact_index_controller.rb @@ -33,6 +33,7 @@ def render_range(response_body) headers["Digest"] = "sha-256=#{digest}" headers["Repr-Digest"] = "sha-256=:#{digest}:" headers["Accept-Ranges"] = "bytes" + headers["Content-Type"] = "text/plain; charset=utf-8" ranges = Rack::Utils.byte_ranges(request.env, response_body.bytesize) if ranges diff --git a/app/controllers/api/v1/api_keys_controller.rb b/app/controllers/api/v1/api_keys_controller.rb index a4c7d53b30d..a89511f998f 100644 --- a/app/controllers/api/v1/api_keys_controller.rb +++ b/app/controllers/api/v1/api_keys_controller.rb @@ -49,10 +49,13 @@ def update def check_mfa(user) if user&.mfa_gem_signin_authorized?(otp) - return render_mfa_setup_required_error if user.mfa_required_not_yet_enabled? - return render_mfa_strong_level_required_error if user.mfa_required_weak_level_enabled? - - yield + if user.mfa_required_not_yet_enabled? + render_forbidden t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + elsif user.mfa_required_weak_level_enabled? + render_forbidden t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + else + yield + end elsif user&.mfa_enabled? prompt_text = otp.present? ? t(:otp_incorrect) : t(:otp_missing) render plain: prompt_text, status: :unauthorized diff --git a/app/controllers/api/v1/attestations_controller.rb b/app/controllers/api/v1/attestations_controller.rb new file mode 100644 index 00000000000..87b5f4a97a6 --- /dev/null +++ b/app/controllers/api/v1/attestations_controller.rb @@ -0,0 +1,16 @@ +class Api::V1::AttestationsController < Api::BaseController + before_action :find_version + + def show + respond_to do |format| + format.json { render json: @version.attestations.pluck(:body) } + end + end + + private + + def find_version + @version = Version.find_by(full_name: params[:id].delete_suffix(".json")) || + render(plain: t(:this_rubygem_could_not_be_found), status: :not_found) + end +end diff --git a/app/controllers/api/v1/deletions_controller.rb b/app/controllers/api/v1/deletions_controller.rb index 3dea90ad1ed..bb06d5acae3 100644 --- a/app/controllers/api/v1/deletions_controller.rb +++ b/app/controllers/api/v1/deletions_controller.rb @@ -5,10 +5,9 @@ class Api::V1::DeletionsController < Api::BaseController before_action :verify_api_key_gem_scope before_action :validate_gem_and_version before_action :verify_with_otp - before_action :render_api_key_forbidden, if: :api_key_unauthorized? - before_action :verify_mfa_requirement def create + authorize @rubygem, :yank? # TODO: change to @version @deletion = @api_key.user.deletions.build(version: @version) if @deletion.save StatsD.increment "yank.success" @@ -34,8 +33,7 @@ def validate_gem_and_version render plain: response_with_mfa_warning(t(:this_rubygem_could_not_be_found)), status: :not_found elsif !@rubygem.owned_by?(@api_key.user) - render plain: response_with_mfa_warning("You do not have permission to delete this gem."), - status: :forbidden + render_forbidden response_with_mfa_warning("You do not have permission to delete this gem.") else begin version = params.permit(:version).require(:version) @@ -47,8 +45,4 @@ def validate_gem_and_version end end end - - def api_key_unauthorized? - !@api_key.can_yank_rubygem? - end end diff --git a/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb b/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb index f1bb0a6b9df..e600e3c4ab9 100644 --- a/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb +++ b/app/controllers/api/v1/oidc/rubygem_trusted_publishers_controller.rb @@ -5,7 +5,6 @@ class Api::V1::OIDC::RubygemTrustedPublishersController < Api::BaseController before_action :find_rubygem before_action :verify_with_otp - before_action :verify_mfa_requirement before_action :find_rubygem_trusted_publisher, except: %i[index create] before_action :set_trusted_publisher_type, only: %i[create] @@ -19,7 +18,7 @@ def show end def create - trusted_publisher = authorize @rubygem.oidc_rubygem_trusted_publishers.build(create_params) + trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.build(create_params) if trusted_publisher.save render json: trusted_publisher, status: :created @@ -36,11 +35,11 @@ def destroy def find_rubygem super - authorize @rubygem, :show_trusted_publishers? + authorize @rubygem, :configure_trusted_publishers? end def find_rubygem_trusted_publisher - @rubygem_trusted_publisher = authorize @rubygem.oidc_rubygem_trusted_publishers.find(params.permit(:id).require(:id)) + @rubygem_trusted_publisher = @rubygem.oidc_rubygem_trusted_publishers.find(params.permit(:id).require(:id)) end def set_trusted_publisher_type diff --git a/app/controllers/api/v1/oidc/trusted_publisher_controller.rb b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb index febafa6ce39..3da6fe8d22e 100644 --- a/app/controllers/api/v1/oidc/trusted_publisher_controller.rb +++ b/app/controllers/api/v1/oidc/trusted_publisher_controller.rb @@ -2,6 +2,7 @@ class Api::V1::OIDC::TrustedPublisherController < Api::BaseController include ApiKeyable before_action :decode_jwt + before_action :validate_jwt_format before_action :find_provider before_action :verify_signature before_action :find_trusted_publisher @@ -9,6 +10,9 @@ class Api::V1::OIDC::TrustedPublisherController < Api::BaseController class UnsupportedIssuer < StandardError; end class UnverifiedJWT < StandardError; end + class InvalidJWT < StandardError; end + + rescue_from InvalidJWT, with: :render_bad_request rescue_from( UnsupportedIssuer, UnverifiedJWT, @@ -40,9 +44,18 @@ def exchange_token def decode_jwt @jwt = JSON::JWT.decode_compact_serialized(params.permit(:jwt).require(:jwt), :skip_verification) - rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError + rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError => e # invalid base64 raises ArgumentError - render_bad_request + render_bad_request(e) + end + + def validate_jwt_format + %w[nbf iat exp].each do |claim| + raise InvalidJWT, "Missing/invalid #{claim}" unless @jwt[claim].is_a?(Integer) + end + %w[iss jti].each do |claim| + raise InvalidJWT, "Missing/invalid #{claim}" unless @jwt[claim].is_a?(String) + end end def find_provider @@ -65,8 +78,4 @@ def find_trusted_publisher def validate_claims @trusted_publisher.to_access_policy(@jwt).verify_access!(@jwt) end - - def render_bad_request - render json: { error: "Bad Request" }, status: :bad_request - end end diff --git a/app/controllers/api/v1/owners_controller.rb b/app/controllers/api/v1/owners_controller.rb index d9f00ea7a64..ec4e6ed3f1c 100644 --- a/app/controllers/api/v1/owners_controller.rb +++ b/app/controllers/api/v1/owners_controller.rb @@ -1,11 +1,9 @@ class Api::V1::OwnersController < Api::BaseController before_action :authenticate_with_api_key, except: %i[show gems] - before_action :verify_user_api_key, except: %i[show gems] - before_action :find_rubygem, except: :gems - before_action :verify_api_key_gem_scope, except: %i[show gems] - before_action :verify_gem_ownership, except: %i[show gems] - before_action :verify_mfa_requirement, except: %i[show gems] before_action :verify_with_otp, except: %i[show gems] + before_action :find_rubygem, except: :gems + + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found def show respond_to do |format| @@ -15,65 +13,74 @@ def show end def create - return render_api_key_forbidden unless @api_key.can_add_owner? + authorize @rubygem, :add_owner? + owner = User.find_by_name!(email_param) + ownership = @rubygem.ownerships.new(user: owner, authorizer: @api_key.user, **ownership_params) + + if ownership.save + OwnersMailer.ownership_confirmation(ownership).deliver_later + render plain: response_with_mfa_warning("#{owner.display_handle} was added as an unconfirmed owner. " \ + "Ownership access will be enabled after the user clicks on the " \ + "confirmation mail sent to their email.") + else + render plain: response_with_mfa_warning(ownership.errors.full_messages.to_sentence), status: :unprocessable_entity + end + end + def update owner = User.find_by_name(email_param) - if owner - ownership = @rubygem.ownerships.new(user: owner, authorizer: @api_key.user) - if ownership.save - OwnersMailer.ownership_confirmation(ownership).deliver_later - render plain: response_with_mfa_warning("#{owner.display_handle} was added as an unconfirmed owner. " \ - "Ownership access will be enabled after the user clicks on the " \ - "confirmation mail sent to their email.") - else - render plain: response_with_mfa_warning(ownership.errors.full_messages.to_sentence), status: :unprocessable_entity - end + ownership = @rubygem.ownerships.find_by(user: owner) if owner + if ownership + authorize(ownership) else - render plain: response_with_mfa_warning("Owner could not be found."), status: :not_found + authorize(@rubygem, :update_owner?) # don't leak presence of an email unless authorized + return render_not_found + end + + if ownership.update(ownership_params) + render plain: response_with_mfa_warning("Owner updated successfully.") + else + render plain: response_with_mfa_warning(ownership.errors.full_messages.to_sentence), status: :unprocessable_entity end end def destroy - return render_api_key_forbidden unless @api_key.can_remove_owner? + authorize @rubygem, :remove_owner? + owner = User.find_by_name!(email_param) + ownership = @rubygem.ownerships_including_unconfirmed.find_by!(user: owner) - owner = @rubygem.owners_including_unconfirmed.find_by_name(email_param) - if owner - ownership = @rubygem.ownerships_including_unconfirmed.find_by(user_id: owner.id) - if ownership.safe_destroy - OwnersMailer.owner_removed(ownership.user_id, @api_key.user.id, ownership.rubygem_id).deliver_later - render plain: response_with_mfa_warning("Owner removed successfully.") - else - render plain: response_with_mfa_warning("Unable to remove owner."), status: :forbidden - end + if ownership.safe_destroy + OwnersMailer.owner_removed(ownership.user_id, @api_key.user.id, ownership.rubygem_id).deliver_later + render plain: response_with_mfa_warning("Owner removed successfully.") else - render plain: response_with_mfa_warning("Owner could not be found."), status: :not_found + render plain: response_with_mfa_warning("Unable to remove owner."), status: :forbidden end end def gems - user = User.find_by_slug(params[:handle]) - if user - rubygems = user.rubygems.with_versions.preload( - :linkset, :gem_download, - most_recent_version: { dependencies: :rubygem, gem_download: nil } - ).strict_loading - respond_to do |format| - format.json { render json: rubygems } - format.yaml { render yaml: rubygems } - end - else - render plain: "Owner could not be found.", status: :not_found + owner = User.find_by_slug!(params[:handle]) + rubygems = owner.rubygems.with_versions.preload( + :linkset, :gem_download, + most_recent_version: { dependencies: :rubygem, gem_download: nil } + ).strict_loading + + respond_to do |format| + format.json { render json: rubygems } + format.yaml { render yaml: rubygems } end end protected - def verify_gem_ownership - return if @api_key.user.rubygems.find_by_name(params[:rubygem_id]) - render plain: response_with_mfa_warning("You do not have permission to manage this gem."), status: :unauthorized + def render_not_found + render plain: response_with_mfa_warning("Owner could not be found."), status: :not_found end def email_param params.permit(:email).require(:email) end + + def ownership_params + params.permit(:role) + end end diff --git a/app/controllers/api/v1/rubygems_controller.rb b/app/controllers/api/v1/rubygems_controller.rb index 4d6793e1922..2d759f98de7 100644 --- a/app/controllers/api/v1/rubygems_controller.rb +++ b/app/controllers/api/v1/rubygems_controller.rb @@ -4,12 +4,10 @@ class Api::V1::RubygemsController < Api::BaseController before_action :find_rubygem, only: %i[show reverse_dependencies] before_action :cors_preflight_check, only: :show before_action :verify_with_otp, only: %i[create] - before_action :verify_mfa_requirement, only: %i[create] after_action :cors_set_access_control_headers, only: :show def index - return render_forbidden unless @api_key.can_index_rubygems? - + authorize Rubygem, :index? @rubygems = @api_key.user.rubygems.with_versions .preload(:linkset, :gem_download, most_recent_version: { dependencies: :rubygem, gem_download: nil }) respond_to do |format| @@ -33,11 +31,26 @@ def show end def create - return render_api_key_forbidden unless @api_key.can_push_rubygem? + authorize Rubygem, :create? + return render_forbidden(t(:api_key_insufficient_scope)) unless @api_key.can_push_rubygem? + + gem_body = attestations = nil + if %w[multipart/form-data multipart/mixed].include?(request.media_type) + gem_body = params.permit(:gem).require(:gem) + return render_bad_request("gem is not a file upload") unless gem_body.is_a?(ActionDispatch::Http::UploadedFile) + return render_bad_request("missing attestations") unless (attestations = params[:attestations]).is_a?(String) + attestations = ActiveSupport::JSON.decode(attestations) + return render_bad_request("attestations must be an array, is #{attestations.class}") unless attestations.is_a?(Array) + attestations = attestations&.as_json + else + gem_body = request.body + end - gemcutter = Pusher.new(@api_key, request.body, request:) + gemcutter = Pusher.new(@api_key, gem_body, request:, attestations:) gemcutter.process render plain: response_with_mfa_warning(gemcutter.message), status: gemcutter.code + rescue Pundit::NotAuthorizedError + raise # allow rescue_from in base_controller to handle this rescue StandardError => e Rails.error.report(e, handled: true) render plain: "Server error. Please try again.", status: :internal_server_error diff --git a/app/controllers/api/v1/timeframe_versions_controller.rb b/app/controllers/api/v1/timeframe_versions_controller.rb index 0259eb85712..31007ea9a31 100644 --- a/app/controllers/api/v1/timeframe_versions_controller.rb +++ b/app/controllers/api/v1/timeframe_versions_controller.rb @@ -31,7 +31,7 @@ def from_time def to_time @to_time ||= params[:to].blank? ? Time.zone.now : Time.iso8601(params[:to]) - rescue ArgumentError + rescue ArgumentError, TypeError raise InvalidTimeframeParameterError, 'the "to" parameter must be iso8601 formatted' end diff --git a/app/controllers/api/v1/web_hooks_controller.rb b/app/controllers/api/v1/web_hooks_controller.rb index 301d73a20b5..77a66eb1fd6 100644 --- a/app/controllers/api/v1/web_hooks_controller.rb +++ b/app/controllers/api/v1/web_hooks_controller.rb @@ -1,10 +1,10 @@ class Api::V1::WebHooksController < Api::BaseController before_action :authenticate_with_api_key before_action :verify_user_api_key - before_action :render_api_key_forbidden, if: :api_key_unauthorized? before_action :find_rubygem_by_name, :set_url, except: :index def index + authorize WebHook respond_to do |format| format.json { render json: @api_key.user.all_hooks } format.yaml { render yaml: @api_key.user.all_hooks } @@ -12,7 +12,7 @@ def index end def create - webhook = @api_key.user.web_hooks.build(url: @url, rubygem: @rubygem) + webhook = authorize @api_key.user.web_hooks.build(url: @url, rubygem: @rubygem) if webhook.save render(plain: webhook.success_message, status: :created) else @@ -21,7 +21,7 @@ def create end def remove - webhook = @api_key.user.web_hooks.find_by_rubygem_id_and_url(@rubygem&.id, @url) + webhook = authorize @api_key.user.web_hooks.find_by_rubygem_id_and_url(@rubygem&.id, @url) if webhook&.destroy render(plain: webhook.removed_message) else @@ -33,11 +33,15 @@ def fire webhook = @api_key.user.web_hooks.new(url: @url) @rubygem ||= Rubygem.find_by_name("gemcutter") - if webhook.fire(request.protocol.delete("://"), request.host_with_port, - @rubygem.most_recent_version, delayed: false) - render plain: webhook.deployed_message(@rubygem) + authorize webhook + + response = webhook.fire(request.protocol.delete("://"), request.host_with_port, + @rubygem.most_recent_version, delayed: false) + + if response.fetch("status") == "success" + render plain: webhook.deployed_message(@rubygem) + hook_relay_message(response) else - render_bad_request webhook.failed_message(@rubygem) + render_bad_request webhook.failed_message(@rubygem) + hook_relay_message(response) end end @@ -54,7 +58,20 @@ def set_url @url = params[:url] end - def api_key_unauthorized? - !@api_key.can_access_webhooks? + def hook_relay_message(response) + status = response.fetch("status") + msg = +"" + msg << "\nFailed with status #{status.inspect}: #{response['failure_reason']}" if status != "success" + if response.key?("responses") && response["responses"].any? + r = response.dig("responses", -1) + msg << "\nError: #{r['error']}" if r["error"] + msg << "\n\nResponse: #{r['code']}" + r.fetch("headers", []).each do |k, v| + msg << "\n#{k}: #{v}" + end + msg << "\n\n#{r['body']}" + end + + msg end end diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb index 3be2f3403fa..65663f0cff9 100644 --- a/app/controllers/api_keys_controller.rb +++ b/app/controllers/api_keys_controller.rb @@ -1,5 +1,6 @@ class ApiKeysController < ApplicationController before_action :disable_cache, only: :index + before_action :set_page, only: :index include ApiKeyable @@ -8,7 +9,7 @@ class ApiKeysController < ApplicationController def index @api_key = session.delete(:api_key) - @api_keys = current_user.api_keys.unexpired.not_oidc.preload(ownership: :rubygem) + @api_keys = current_user.api_keys.unexpired.not_oidc.preload(ownership: :rubygem).page(@page) redirect_to new_profile_api_key_path if @api_keys.empty? end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e9fe1221041..65a6f65e107 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,7 +9,9 @@ class ApplicationController < ActionController::Base rescue_from ActiveRecord::RecordNotFound, with: :render_not_found rescue_from ActionController::InvalidAuthenticityToken, with: :render_forbidden rescue_from ActionController::UnpermittedParameters, with: :render_bad_request - rescue_from(Pundit::NotAuthorizedError) { |_| render_forbidden } # don't pass pundit error message + rescue_from(Pundit::NotAuthorizedError) do |e| + render_forbidden(e.policy.error) + end before_action :set_locale before_action :reject_null_char_param @@ -60,6 +62,16 @@ def self.http_basic_authenticate_with(**options) super end + def breadcrumbs + @breadcrumbs ||= [] + end + helper_method :breadcrumbs + + def add_breadcrumb(name, link = nil) + breadcrumbs << [name, link] + end + helper_method :add_breadcrumb + protected def http_basic_authentication_options_valid?(**options) @@ -105,10 +117,6 @@ def find_rubygem end end - def owner? - @rubygem.owned_by?(current_user) - end - def find_versioned_links @versioned_links = @rubygem.links(@latest_version) end @@ -138,7 +146,8 @@ def render_not_found end end - def render_forbidden(error = "forbidden") + def render_forbidden(error = nil) + error ||= t(:forbidden) render plain: error, status: :forbidden end diff --git a/app/controllers/avo/attestations_controller.rb b/app/controllers/avo/attestations_controller.rb new file mode 100644 index 00000000000..053dc201472 --- /dev/null +++ b/app/controllers/avo/attestations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::AttestationsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/memberships_controller.rb b/app/controllers/avo/memberships_controller.rb new file mode 100644 index 00000000000..679726950fa --- /dev/null +++ b/app/controllers/avo/memberships_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::MembershipsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/organization_onboarding_invites_controller.rb b/app/controllers/avo/organization_onboarding_invites_controller.rb new file mode 100644 index 00000000000..fc4bf11c51b --- /dev/null +++ b/app/controllers/avo/organization_onboarding_invites_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OrganizationOnboardingInvitesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/organization_onboardings_controller.rb b/app/controllers/avo/organization_onboardings_controller.rb new file mode 100644 index 00000000000..d9575f8fbbb --- /dev/null +++ b/app/controllers/avo/organization_onboardings_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OrganizationOnboardingsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/organizations_controller.rb b/app/controllers/avo/organizations_controller.rb new file mode 100644 index 00000000000..351db7d415e --- /dev/null +++ b/app/controllers/avo/organizations_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/2.0/controllers.html +class Avo::OrganizationsController < Avo::ResourcesController +end diff --git a/app/controllers/concerns/avo_auditable.rb b/app/controllers/concerns/avo_auditable.rb index bf7643f1005..2fdabd8772a 100644 --- a/app/controllers/concerns/avo_auditable.rb +++ b/app/controllers/concerns/avo_auditable.rb @@ -12,17 +12,17 @@ def perform_action_and_record_errors(&blk) action = params.fetch(:action) fields = action == "destroy" ? {} : cast_nullable(model_params) - @model.errors.add :comment, "must supply a sufficiently detailed comment" if fields[:comment]&.then { _1.length < 10 } - raise ActiveRecord::RecordInvalid, @model if @model.errors.present? - action_name = "Manual #{action} of #{@model.class}" + @record.errors.add :comment, "must supply a sufficiently detailed comment" if fields[:comment]&.then { _1.length < 10 } + raise ActiveRecord::RecordInvalid, @record if @record.errors.present? + action_name = "Manual #{action} of #{@record.class}" value, @audit = in_audited_transaction( - auditable: @model, + auditable: @record, admin_github_user: _current_user, action: action_name, fields: fields.reverse_merge(comment: action_name), arguments: {}, - models: [@model], + models: [@record], &blk ) value diff --git a/app/controllers/concerns/require_mfa.rb b/app/controllers/concerns/require_mfa.rb index 8dc1c3195e2..872ed47833d 100644 --- a/app/controllers/concerns/require_mfa.rb +++ b/app/controllers/concerns/require_mfa.rb @@ -54,12 +54,12 @@ def incorrect_otp end def webauthn_failure - invalidate_mfa_session(@webauthn_error) + mfa_failure(@webauthn_error) end def invalidate_mfa_session(message) delete_mfa_session - mfa_failure(message) + login_failure(message) end def delete_mfa_session diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 79e8b999ce6..4cf72c275cd 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -4,12 +4,16 @@ class DashboardsController < ApplicationController before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + layout "subject" + def show + add_breadcrumb t("breadcrumbs.dashboard") + respond_to do |format| format.html do - @my_gems = current_user.rubygems.with_versions.by_name.preload(:most_recent_version) - @latest_updates = Version.subscribed_to_by(current_user).published.limit(Gemcutter::DEFAULT_PAGINATION) - @subscribed_gems = current_user.subscribed_gems.with_versions + find_my_gems + find_subscribed_gems + find_latest_updates end format.atom do @versions = Version.subscribed_to_by(api_or_logged_in_user).published.limit(Gemcutter::DEFAULT_PAGINATION) @@ -32,4 +36,33 @@ def authenticate_with_api_key def api_or_logged_in_user current_user || @api_key.user end + + def find_my_gems + @my_gems_count = current_user.rubygems.with_versions.count + @my_gems = current_user + .rubygems + .with_versions + .by_downloads + .preload(:most_recent_version, :gem_download) + .load_async + end + + def find_subscribed_gems + @subscribed_gems_count = current_user.subscribed_gems.with_versions.count + @subscribed_gems = current_user + .subscribed_gems + .with_versions + .limit(5) + .preload(:most_recent_version) + .load_async + end + + def find_latest_updates + @latest_updates = Version + .subscribed_to_by(current_user) + .published + .limit(Gemcutter::DEFAULT_PAGINATION) + .preload(:rubygem, :pusher, pusher_api_key: :owner) + .load_async + end end diff --git a/app/controllers/multifactor_auths_controller.rb b/app/controllers/multifactor_auths_controller.rb index efd8cebb682..ccc4fe14113 100644 --- a/app/controllers/multifactor_auths_controller.rb +++ b/app/controllers/multifactor_auths_controller.rb @@ -84,12 +84,13 @@ def mfa_failure(message) delete_mfa_session redirect_to edit_settings_path, flash: { error: message } end + alias login_failure mfa_failure def otp_verification_url - otp_update_multifactor_auth_url(token: current_user.confirmation_token, level: level_param) + otp_update_multifactor_auth_url(level: level_param) end def webauthn_verification_url - webauthn_update_multifactor_auth_url(token: current_user.confirmation_token, level: level_param) + webauthn_update_multifactor_auth_url(level: level_param) end end diff --git a/app/controllers/oidc/rubygem_trusted_publishers_controller.rb b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb index 2f6b30b2f31..14ba48a3faf 100644 --- a/app/controllers/oidc/rubygem_trusted_publishers_controller.rb +++ b/app/controllers/oidc/rubygem_trusted_publishers_controller.rb @@ -53,7 +53,7 @@ def create_params_key = :oidc_rubygem_trusted_publisher def find_rubygem super - authorize @rubygem, :show_trusted_publishers? + authorize @rubygem, :configure_trusted_publishers? end def find_rubygem_trusted_publisher diff --git a/app/controllers/organizations/onboarding/base_controller.rb b/app/controllers/organizations/onboarding/base_controller.rb new file mode 100644 index 00000000000..67eae35ded2 --- /dev/null +++ b/app/controllers/organizations/onboarding/base_controller.rb @@ -0,0 +1,28 @@ +class Organizations::Onboarding::BaseController < ApplicationController + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + before_action :find_or_initialize_onboarding + before_action :set_breadcrumbs + + def find_or_initialize_onboarding + @organization_onboarding = OrganizationOnboarding.find_or_initialize_by(created_by: Current.user, status: :pending) + end + + def set_breadcrumbs + add_breadcrumb t("breadcrumbs.dashboard"), dashboard_path + add_breadcrumb "Create Org" + end + + def available_rubygems + @available_rubygems ||= @organization_onboarding.available_rubygems.to_a.tap do |gems| + namesake_rubygem = @organization_onboarding.namesake_rubygem + gems.unshift gems.delete(namesake_rubygem) if namesake_rubygem + end + end + helper_method :available_rubygems + + def approved_invites + @approved_invites ||= @organization_onboarding.approved_invites + end + helper_method :approved_invites +end diff --git a/app/controllers/organizations/onboarding/confirm_controller.rb b/app/controllers/organizations/onboarding/confirm_controller.rb new file mode 100644 index 00000000000..bba2ae80f4f --- /dev/null +++ b/app/controllers/organizations/onboarding/confirm_controller.rb @@ -0,0 +1,16 @@ +class Organizations::Onboarding::ConfirmController < Organizations::Onboarding::BaseController + layout "onboarding" + + def edit + end + + def update + @organization_onboarding.onboard! + + flash[:notice] = I18n.t("organization_onboardings.confirm.success") + redirect_to organization_path(@organization_onboarding.organization) + rescue ActiveRecord::ActiveRecordError + flash.now[:error] = "Onboarding error: #{@organization_onboarding.error}" + render :edit, status: :unprocessable_entity + end +end diff --git a/app/controllers/organizations/onboarding/gems_controller.rb b/app/controllers/organizations/onboarding/gems_controller.rb new file mode 100644 index 00000000000..c80825ee2f8 --- /dev/null +++ b/app/controllers/organizations/onboarding/gems_controller.rb @@ -0,0 +1,20 @@ +class Organizations::Onboarding::GemsController < Organizations::Onboarding::BaseController + layout "onboarding" + + def edit + end + + def update + if @organization_onboarding.update(onboarding_gems_params) + redirect_to organization_onboarding_users_path + else + render :edit, status: :unprocessable_entity + end + end + + private + + def onboarding_gems_params + params.permit(organization_onboarding: { rubygems: [] }).fetch(:organization_onboarding, {}) + end +end diff --git a/app/controllers/organizations/onboarding/name_controller.rb b/app/controllers/organizations/onboarding/name_controller.rb new file mode 100644 index 00000000000..e61ccf376fe --- /dev/null +++ b/app/controllers/organizations/onboarding/name_controller.rb @@ -0,0 +1,20 @@ +class Organizations::Onboarding::NameController < Organizations::Onboarding::BaseController + layout "onboarding" + + def new + end + + def create + if @organization_onboarding.update(onboarding_name_params) + redirect_to organization_onboarding_gems_path + else + render :new, status: :unprocessable_entity + end + end + + private + + def onboarding_name_params + params.require(:organization_onboarding).permit(:organization_name, :organization_handle) + end +end diff --git a/app/controllers/organizations/onboarding/users_controller.rb b/app/controllers/organizations/onboarding/users_controller.rb new file mode 100644 index 00000000000..94dc7d3d571 --- /dev/null +++ b/app/controllers/organizations/onboarding/users_controller.rb @@ -0,0 +1,27 @@ +class Organizations::Onboarding::UsersController < Organizations::Onboarding::BaseController + layout "onboarding" + + def edit + end + + def update + if @organization_onboarding.update(onboarding_user_params) + redirect_to organization_onboarding_confirm_path + else + render :edit, status: :unprocessable_entity + end + end + + private + + def role_options + @role_options ||= OrganizationOnboardingInvite.roles.map do |k, _| + [Membership.human_attribute_name("role.#{k}"), k] + end + end + helper_method :role_options + + def onboarding_user_params + params.require(:organization_onboarding).permit(invites_attributes: %i[id role]) + end +end diff --git a/app/controllers/organizations/onboarding_controller.rb b/app/controllers/organizations/onboarding_controller.rb new file mode 100644 index 00000000000..fe66a4ab034 --- /dev/null +++ b/app/controllers/organizations/onboarding_controller.rb @@ -0,0 +1,10 @@ +class Organizations::OnboardingController < ApplicationController + before_action :redirect_to_signin, unless: :signed_in? + before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? + + def destroy + OrganizationOnboarding.destroy_by(created_by: Current.user, status: %i[pending failed]) + + redirect_to dashboard_path + end +end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb new file mode 100644 index 00000000000..8ea9cb0e87b --- /dev/null +++ b/app/controllers/organizations_controller.rb @@ -0,0 +1,10 @@ +class OrganizationsController < ApplicationController + def show + render plain: flash[:notice] # HACK: for tests until this view is ready + end + + private + + def organization_params + end +end diff --git a/app/controllers/owners_controller.rb b/app/controllers/owners_controller.rb index 82735cee4ac..1c393af0dfa 100644 --- a/app/controllers/owners_controller.rb +++ b/app/controllers/owners_controller.rb @@ -2,8 +2,13 @@ class OwnersController < ApplicationController include SessionVerifiable before_action :find_rubygem, except: :confirm - verify_session_before only: %i[index create destroy] - before_action :verify_mfa_requirement, only: %i[create destroy] + verify_session_before only: %i[index edit update create destroy] + before_action :verify_mfa_requirement, only: %i[create edit update destroy] + before_action :find_ownership, only: %i[edit update destroy] + + rescue_from(Pundit::NotAuthorizedError) do |e| + redirect_to rubygem_path(@rubygem.slug), alert: e.policy.error + end def confirm ownership = Ownership.find_by!(token: token_params) @@ -32,9 +37,14 @@ def index @ownerships = @rubygem.ownerships_including_unconfirmed.includes(:user, :authorizer) end + def edit + end + def create + authorize @rubygem, :add_owner? owner = User.find_by_name(handle_params) - ownership = authorize @rubygem.ownerships.new(user: owner, authorizer: current_user) + + ownership = @rubygem.ownerships.new(user: owner, authorizer: current_user, role: params[:role]) if ownership.save OwnersMailer.ownership_confirmation(ownership).deliver_later redirect_to rubygem_owners_path(@rubygem.slug), notice: t(".success_notice", handle: owner.name) @@ -43,8 +53,19 @@ def create end end + # This action is used to update a user's owenrship role. This endpoint currently asssumes + # the role is the only thing that can be updated. If more fields are added to the ownership + # this action will need to be tweaked a bit + def update + if @ownership.update(update_params) + OwnersMailer.with(ownership: @ownership, authorizer: current_user).owner_updated.deliver_later + redirect_to rubygem_owners_path(@ownership.rubygem.slug), notice: t(".success_notice", handle: @ownership.user.name) + else + index_with_error @ownership.errors.full_messages.to_sentence, :unprocessable_entity + end + end + def destroy - @ownership = authorize @rubygem.ownerships_including_unconfirmed.find_by_owner_handle!(handle_params) if @ownership.safe_destroy OwnersMailer.owner_removed(@ownership.user_id, current_user.id, @ownership.rubygem_id).deliver_later redirect_to rubygem_owners_path(@ownership.rubygem.slug), notice: t(".removed_notice", owner_name: @ownership.owner_name) @@ -59,6 +80,15 @@ def verify_session_redirect_path rubygem_owners_url(params[:rubygem_id]) end + def find_ownership + @ownership = @rubygem.ownerships_including_unconfirmed.find_by_owner_handle(handle_params) + return authorize(@ownership) if @ownership + + predicate = params[:action] == "destroy" ? :remove_owner? : :update_owner? + authorize(@rubygem, predicate) + render_not_found + end + def token_params params.permit(:token).require(:token) end @@ -67,6 +97,10 @@ def handle_params params.permit(:handle).require(:handle) end + def update_params + params.permit(:role) + end + def notify_owner_added(ownership) ownership.rubygem.ownership_notifiable_owners.each do |notified_user| OwnersMailer.owner_added( diff --git a/app/controllers/ownership_calls_controller.rb b/app/controllers/ownership_calls_controller.rb index 6a33c8b52c4..67fd36844cf 100644 --- a/app/controllers/ownership_calls_controller.rb +++ b/app/controllers/ownership_calls_controller.rb @@ -1,8 +1,11 @@ class OwnershipCallsController < ApplicationController + include SessionVerifiable + before_action :find_rubygem, except: :index before_action :redirect_to_signin, unless: :signed_in?, except: :index before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled?, except: :index before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled?, except: :index + before_action :redirect_to_verify, only: %i[create close], unless: :verified_session_active? before_action :find_ownership_call, only: :close rescue_from ActiveRecord::RecordInvalid, with: :redirect_try_again diff --git a/app/controllers/ownership_requests_controller.rb b/app/controllers/ownership_requests_controller.rb index b1152961557..48d8f887e05 100644 --- a/app/controllers/ownership_requests_controller.rb +++ b/app/controllers/ownership_requests_controller.rb @@ -1,8 +1,11 @@ class OwnershipRequestsController < ApplicationController + include SessionVerifiable + before_action :find_rubygem before_action :redirect_to_signin, unless: :signed_in? before_action :redirect_to_new_mfa, if: :mfa_required_not_yet_enabled? before_action :redirect_to_settings_strong_mfa_required, if: :mfa_required_weak_level_enabled? + before_action :redirect_to_verify, only: %i[update close_all], if: -> { policy(@rubygem).manage_adoption? && !verified_session_active? } rescue_from ActiveRecord::RecordInvalid, with: :redirect_try_again rescue_from ActiveRecord::RecordNotSaved, with: :redirect_try_again @@ -31,7 +34,7 @@ def update end def close_all - authorize(@rubygem, :close_ownership_requests?).ownership_requests.each(&:close!) + authorize(@rubygem, :manage_adoption?).ownership_requests.each(&:close!) redirect_to rubygem_adoptions_path(@rubygem.slug), notice: t("ownership_requests.close.success_notice", gem: @rubygem.name) end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 767ce234925..c47d16291c4 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,7 +1,10 @@ class PagesController < ApplicationController before_action :find_page + layout "hammy" + def show + add_breadcrumb t("pages.#{@page}.title") render @page end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index b979302e3d3..9a20498dcfd 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -2,7 +2,6 @@ class PasswordsController < ApplicationController include MfaExpiryMethods include RequireMfa include WebauthnVerifiable - include SessionVerifiable before_action :ensure_email_present, only: %i[create] @@ -11,16 +10,15 @@ class PasswordsController < ApplicationController before_action :require_mfa, only: %i[edit] before_action :validate_otp, only: %i[otp_edit] before_action :validate_webauthn, only: %i[webauthn_edit] + before_action :password_reset_session_verified, only: %i[edit otp_edit webauthn_edit] after_action :delete_mfa_expiry_session, only: %i[otp_edit webauthn_edit] - verify_session_before only: %i[update] + before_action :validate_password_reset_session, only: :update def new end def edit - # When user doesn't have mfa, a valid token is a full "magic link" sign in. - verified_sign_in render :edit end @@ -36,11 +34,11 @@ def create end def update - if current_user.update_password reset_params[:password] - current_user.reset_api_key! if reset_params[:reset_api_key] == "true" # singular - current_user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true" # plural - redirect_to dashboard_path - session[:password_reset_token] = nil + if @user.update_password reset_params[:password] + @user.reset_api_key! if reset_params[:reset_api_key] == "true" # singular + @user.api_keys.expire_all! if reset_params[:reset_api_keys] == "true" # plural + delete_password_reset_session + redirect_to signed_in? ? dashboard_path : sign_in_path else flash.now[:alert] = t(".failure") render :edit @@ -48,28 +46,15 @@ def update end def otp_edit - verified_sign_in render :edit end def webauthn_edit - verified_sign_in render :edit end private - def verified_sign_in - sign_in @user - session_verified - @user.update!(confirmation_token: nil) - StatsD.increment "login.success" - end - - def reset_params - params.fetch(:password_reset, {}).permit(:password, :reset_api_key, :reset_api_keys) - end - def ensure_email_present @email = params.dig(:password, :email) return if @email.present? @@ -79,22 +64,47 @@ def ensure_email_present end def validate_confirmation_token - @user = User.find_by(confirmation_token: params[:token].to_s) - redirect_to root_path, alert: t("passwords.edit.token_failure") unless @user&.valid_confirmation_token? + confirmation_token = params.permit(:token).fetch(:token, "").to_s + @user = User.find_by(confirmation_token:) + return login_failure(t("passwords.edit.token_failure")) unless @user&.valid_confirmation_token? + sign_out if signed_in? && @user != current_user end - def login_failure(message) - flash.now.alert = message - render template: "multifactor_auths/prompt", status: :unauthorized + # The order of these methods intends to leave the session fully reset if we + # fail to invalidate the token for some reason, since this would indicate + # something is wrong with the user, necessitating help from an admin. + def password_reset_session_verified + reset_session + @user.update!(confirmation_token: nil) + session[:password_reset_verified_user] = @user.id + session[:password_reset_verified] = Gemcutter::PASSWORD_VERIFICATION_EXPIRY.from_now + end + + def validate_password_reset_session + return login_failure(t("passwords.edit.token_failure")) if session[:password_reset_verified].nil? + return login_failure(t("verification_expired")) if session[:password_reset_verified] < Time.current + @user = User.find_by(id: session[:password_reset_verified_user]) + login_failure(t("verification_expired")) unless @user + end + + def delete_password_reset_session + delete_mfa_session + session.delete(:password_reset_verified_user) + session.delete(:password_reset_verified) + end + + def reset_params + params.permit(password_reset: %i[password reset_api_key reset_api_keys]).require(:password_reset) end def mfa_failure(message) - login_failure(message) + flash.now.alert = message + render template: "multifactor_auths/prompt", status: :unauthorized end - def redirect_to_verify - session[:redirect_uri] = verify_session_redirect_path - redirect_to verify_session_path, alert: t("verification_expired") + def login_failure(alert) + reset_session + redirect_to sign_in_path, alert: end def otp_verification_url diff --git a/app/controllers/rubygems_controller.rb b/app/controllers/rubygems_controller.rb index 2d0b9eb8709..e5a9ef07d30 100644 --- a/app/controllers/rubygems_controller.rb +++ b/app/controllers/rubygems_controller.rb @@ -1,9 +1,9 @@ class RubygemsController < ApplicationController include LatestVersion - before_action :set_reserved_gem, only: %i[show security_events], if: :reserved? - before_action :find_rubygem, only: %i[show security_events], unless: :reserved? - before_action :latest_version, only: %i[show], unless: :reserved? - before_action :find_versioned_links, only: %i[show], unless: :reserved? + before_action :show_reserved_gem, only: %i[show security_events] + before_action :find_rubygem, only: %i[show security_events] + before_action :latest_version, only: %i[show] + before_action :find_versioned_links, only: %i[show] before_action :set_page, only: :index before_action :redirect_to_signin, unless: :signed_in?, only: %i[security_events] @@ -21,16 +21,12 @@ def index end def show - if @reserved_gem - render "reserved" + @versions = @rubygem.public_versions.limit(5) + @adoption = @rubygem.ownership_call + if @versions.to_a.any? + render "show" else - @versions = @rubygem.public_versions.limit(5) - @adoption = @rubygem.ownership_call - if @versions.to_a.any? - render "show" - else - render "show_yanked" - end + render "show_yanked" end end @@ -42,12 +38,10 @@ def security_events private - def reserved? - GemNameReservation.reserved?(params[:id]) - end - - def set_reserved_gem + def show_reserved_gem + return unless GemNameReservation.reserved?(params[:id]) @reserved_gem = params[:id].downcase + render "reserved" end def gem_params diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b424fc0c13f..85a26427723 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -73,6 +73,12 @@ def webauthn_authenticate end end + def development_log_in_as + user = User.find(params[:user_id]) + sign_in(user) + redirect_back_or_to dashboard_path + end + private def mark_verified @@ -109,6 +115,10 @@ def login_failure(message) render "sessions/new", status: :unauthorized end + def webauthn_failure + invalidate_mfa_session(@webauthn_error) + end + def mfa_failure(message) login_failure(message) end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 67ba4419d61..3eeeb53a15c 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -1,5 +1,23 @@ class SubscriptionsController < ApplicationController - before_action :find_rubygem + before_action :redirect_to_signin, only: :index, unless: :signed_in? + before_action :redirect_to_new_mfa, only: :index, if: :mfa_required_not_yet_enabled? + before_action :redirect_to_settings_strong_mfa_required, only: :index, if: :mfa_required_weak_level_enabled? + + before_action :find_rubygem, only: %i[create destroy] + + layout "subject" + + def index + add_breadcrumb t("breadcrumbs.dashboard"), dashboard_path + add_breadcrumb t("breadcrumbs.subscriptions") + + @subscribed_gems = current_user + .subscribed_gems + .with_versions + .by_name + .preload(:most_recent_version) + .load_async + end def create subscription = @rubygem.subscriptions.build(user: current_user) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 62447d8a786..0e650d61de3 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -75,7 +75,7 @@ def flash_message(name, msg) msg end - def search_field(home: false) + def rubygem_search_field(**kwargs) data = { autocomplete_target: "query", action: %w[ @@ -89,17 +89,21 @@ def search_field(home: false) blur->autocomplete#hide ].join(" ") } - data[:nav_target] = "search" unless home + aria = { autocomplete: "list" } + + data.merge!(kwargs.delete(:data) || {}) + aria.merge!(kwargs.delete(:aria) || {}) search_field_tag( :query, params[:query], placeholder: t("layouts.application.header.search_gem_html"), autofocus: current_page?(root_url), - class: home ? "home__search" : "header__search", + class: kwargs[:class], autocomplete: "off", - aria: { autocomplete: "list" }, - data: data + aria:, + data:, + **kwargs ) end end diff --git a/app/helpers/attestations_helper.rb b/app/helpers/attestations_helper.rb new file mode 100644 index 00000000000..9dc4009853f --- /dev/null +++ b/app/helpers/attestations_helper.rb @@ -0,0 +1,2 @@ +module AttestationsHelper +end diff --git a/app/helpers/icon_helper.rb b/app/helpers/icon_helper.rb new file mode 100644 index 00000000000..88a2a9f4847 --- /dev/null +++ b/app/helpers/icon_helper.rb @@ -0,0 +1,14 @@ +module IconHelper + # size is in tailwind units (6 = 24px) + def icon_tag(name, size: 6, **options) + options[:class] = "h-#{size} w-#{size} flex-shrink-0 stroke-current stroke-0 fill-current #{options[:class]}" + options[:height] = size * 4 + options[:width] = size * 4 + options[:aria] ||= { hidden: true } + options[:role] ||= "graphics-symbol" + + tag.svg(**options) do + concat tag.use(href: "#{image_path('icons.svg')}##{name}") + end + end +end diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb deleted file mode 100644 index 5c303f9476c..00000000000 --- a/app/helpers/pages_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -module PagesHelper - def version_number - version&.number || "0.0.0" - end - - def version - Rubygem.current_rubygems_release - end - - def subtitle - subtitle = "v#{version_number}" - subtitle += " - #{nice_date_for(version.authored_at)}" if version - subtitle - end -end diff --git a/app/helpers/prose_helper.rb b/app/helpers/prose_helper.rb new file mode 100644 index 00000000000..32598181f28 --- /dev/null +++ b/app/helpers/prose_helper.rb @@ -0,0 +1,8 @@ +module ProseHelper + def prose(**options, &) + base = "prose prose-neutral dark:prose-invert prose-lg md:prose-xl max-w-prose mx-auto" + styles = "prose-headings:font-semibold" + options[:class] = "#{options[:class]} #{base} #{styles}" + tag.div(**options, &) + end +end diff --git a/app/helpers/rubygems_helper.rb b/app/helpers/rubygems_helper.rb index 7898a9e9475..a6f2db4279c 100644 --- a/app/helpers/rubygems_helper.rb +++ b/app/helpers/rubygems_helper.rb @@ -188,4 +188,34 @@ def github_params(rubygem) size: "large" } end + + def copy_field_tag(name, value) + field = text_field_tag( + name, + value, + id: name, + class: "gem__code", + readonly: "readonly", + data: { clipboard_target: "source" } + ) + + button = tag.span( + "=", + class: "gem__code__icon", + title: t("copy_to_clipboard"), + data: { + action: "click->clipboard#copy", + clipboard_target: "button" + } + ) + + tag.div( + field + button, + class: "gem__code-wrap", + data: { + controller: "clipboard", + clipboard_success_content_value: "✔" + } + ) + end end diff --git a/app/helpers/versions_helper.rb b/app/helpers/versions_helper.rb index 4545f432500..e1047d859f4 100644 --- a/app/helpers/versions_helper.rb +++ b/app/helpers/versions_helper.rb @@ -1,8 +1,8 @@ module VersionsHelper - def version_date_tag(version) + def version_date_tag(version, prefix: nil) data = {} klass = ["gem__version__date"] - date = version_authored_date(version) + date = version_authored_date(version, prefix:) if version.rely_on_built_at? klass << "tooltip__text" data.merge!(tooltip: t("versions.index.imported_gem_version_notice", import_date: nice_date_for(Version::RUBYGEMS_IMPORT_DATE))) @@ -14,7 +14,39 @@ def version_date_tag(version) end end - def version_authored_date(version) - "- #{nice_date_for(version.authored_at)}" + def version_authored_date(version, prefix: nil) + "#{prefix}#{nice_date_for(version.authored_at)}" + end + + def version_number(version) + tag.code( + version.number, + class: "px-2 text-c3 bg-green-200 dark:bg-green-800 rounded-sm text-neutral-900 dark:text-white" + ) + end + + def version_date_component(version, **options) + options[:class] = "flex text-b3 text-neutral-600 dark:text-neutral-400 #{options[:class]}" + options[:data] ||= {} + + if version.rely_on_built_at? + options[:data].merge!(tooltip: t("versions.index.imported_gem_version_notice", import_date: nice_date_for(Version::RUBYGEMS_IMPORT_DATE))) + end + + tag.div(**options) do + concat version_authored_date(version) + concat content_tag(:sup, "*") if version.rely_on_built_at? + end + end + + def download_count_component(rubygem, **options) + downloads = number_with_delimiter(rubygem.downloads) + options[:class] = "flex text-neutral-600 dark:text-neutral-400 text-nowrap text-b3 space-x-1 items-center #{options[:class]}" + options[:title] = "#{t('total_downloads')}: #{downloads}" + + tag.span(**options) do + concat icon_tag("arrow-circle-down", size: 5) + concat tag.span(downloads) + end end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 25a991a9917..50eaafaba76 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,13 +1,17 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +Turbo.session.drive = false + import Rails from "@rails/ujs"; Rails.start(); + +import LocalTime from "local-time" +LocalTime.start() + import "controllers" -import "src/clipboard_buttons"; -import "src/multifactor_auths"; import "src/oidc_api_key_role_form"; import "src/pages"; -import "src/search"; import "src/transitive_dependencies"; import "src/webauthn"; import "github-buttons"; diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index 1213e85c7ac..724e451cf20 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -1,7 +1,13 @@ import { Application } from "@hotwired/stimulus" +import Clipboard from '@stimulus-components/clipboard' +import CheckboxSelectAll from '@stimulus-components/checkbox-select-all' const application = Application.start() +// Add vendored controllers +application.register('clipboard', Clipboard) +application.register('checkbox-select-all', CheckboxSelectAll) + // Configure Stimulus development experience application.debug = false window.Stimulus = application diff --git a/app/javascript/controllers/autocomplete_controller.js b/app/javascript/controllers/autocomplete_controller.js index a840f4eb1d1..f1825ce6466 100644 --- a/app/javascript/controllers/autocomplete_controller.js +++ b/app/javascript/controllers/autocomplete_controller.js @@ -11,9 +11,10 @@ export default class extends Controller { this.suggestLength = 0; } - disconnect() { clear() } + disconnect() { this.clear() } clear() { + this.suggestionsTarget.classList.add('hidden'); this.suggestionsTarget.innerHTML = "" this.suggestionsTarget.removeAttribute('tabindex'); this.suggestionsTarget.removeAttribute('aria-activedescendant'); @@ -25,12 +26,14 @@ export default class extends Controller { } next() { + if (this.suggestLength === 0) return; this.indexNumber++; if (this.indexNumber >= this.suggestLength) this.indexNumber = 0; this.focusItem(this.itemTargets[this.indexNumber]); } prev() { + if (this.suggestLength === 0) return; this.indexNumber--; if (this.indexNumber < 0) this.indexNumber = this.suggestLength - 1; this.focusItem(this.itemTargets[this.indexNumber]); @@ -78,6 +81,7 @@ export default class extends Controller { items.forEach((item, idx) => this.appendItem(item, idx)); this.suggestionsTarget.setAttribute('tabindex', 0); this.suggestionsTarget.setAttribute('role', 'listbox'); + this.suggestionsTarget.classList.remove('hidden'); this.suggestLength = items.length; this.indexNumber = -1; @@ -92,8 +96,9 @@ export default class extends Controller { } focusItem(el, change = true) { - this.itemTargets.forEach(el => el.classList.remove(this.selectedClass)) - el.classList.add(this.selectedClass); + if (!el) { return; } + this.itemTargets.forEach(el => el.classList.remove(...this.selectedClasses)) + el.classList.add(...this.selectedClasses); this.suggestionsTarget.setAttribute('aria-activedescendant', el.id); if (change) { this.queryTarget.value = el.textContent; diff --git a/app/javascript/controllers/counter_controller.js b/app/javascript/controllers/counter_controller.js new file mode 100644 index 00000000000..c3fe216484b --- /dev/null +++ b/app/javascript/controllers/counter_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["counter", "checked"] + + connect() { + this.update() + } + + checkedTargetConnected(el) { + el.addEventListener("change", this.update.bind(this)) + el.addEventListener("input", this.update.bind(this)) // input emitted by checkbox-select-all controller + } + + checkedTargetdisconnected(el) { + el.removeEventListener("change", this.update.bind(this)) + el.removeEventListener("input", this.update.bind(this)) + } + + update() { + const count = this.checkedTargets.filter(el => el.checked).length + this.counterTarget.textContent = count + } +} diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js new file mode 100644 index 00000000000..b3f3c6f479d --- /dev/null +++ b/app/javascript/controllers/dialog_controller.js @@ -0,0 +1,28 @@ +import Dialog from '@stimulus-components/dialog' + +export default class extends Dialog { + static targets = ["dialog", "button"] + + connect() { + super.connect() + this.setAriaExpanded('false') + } + + open(e) { + super.open() + e.preventDefault() + this.setAriaExpanded('true') + } + + close(e) { + super.close() + e.preventDefault() + this.setAriaExpanded('false') + } + + setAriaExpanded(expanded) { + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute('aria-expanded', expanded) + } + } +} diff --git a/app/javascript/controllers/dump_controller.js b/app/javascript/controllers/dump_controller.js new file mode 100644 index 00000000000..7c42fc55582 --- /dev/null +++ b/app/javascript/controllers/dump_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["list", "template"] + + connect() { + this.getDumpData(); + } + + getDumpData() { + fetch('https://s3-us-west-2.amazonaws.com/rubygems-dumps/?prefix=production/public_postgresql') + .then(response => response.text()) + .then(data => { + const parser = new DOMParser(); + const xml = parser.parseFromString(data, "application/xml"); + const files = this.parseS3Listing(xml); + this.render(files); + }) + .catch(error => { + console.error(error); + }); + } + + parseS3Listing(xml) { + const contents = Array.from(xml.getElementsByTagName('Contents')); + return contents.map(item => { + return { + Key: item.getElementsByTagName('Key')[0].textContent, + LastModified: item.getElementsByTagName('LastModified')[0].textContent, + Size: item.getElementsByTagName('Size')[0].textContent, + StorageClass: item.getElementsByTagName('StorageClass')[0].textContent + }; + }); + } + + render(files) { + files + .filter(item => 'STANDARD' === item.StorageClass) + .sort((a, b) => Date.parse(b.LastModified) - Date.parse(a.LastModified)) + .forEach(item => { + let text = `${item.LastModified.replace('.000Z', '')} (${this.bytesToSize(item.Size)})`; + let uri = `https://s3-us-west-2.amazonaws.com/rubygems-dumps/${item.Key}`; + this.appendItem(text, uri); + }); + } + + appendItem(text, uri) { + const clone = this.templateTarget.content.cloneNode(true); + const a = clone.querySelector('a > span') + a.textContent = text; + a.href = uri; + this.element.appendChild(clone) + } + + bytesToSize(bytes) { + if (bytes === 0) { return '0 Bytes' } + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i]; + } +} diff --git a/app/javascript/controllers/onboarding_name_controller.js b/app/javascript/controllers/onboarding_name_controller.js new file mode 100644 index 00000000000..ee6f6466b71 --- /dev/null +++ b/app/javascript/controllers/onboarding_name_controller.js @@ -0,0 +1,74 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "radio", + "gemname", + "username", + "reveal", + "displayname", + "submit", + ] + + connect() { + this.submitTarget.disabled = true + this.radioTargets.forEach((radio) => { + if (radio.checked) { + this[radio.value+"name"]() + } + }) + } + + gemnameField() { + return this.gemnameTarget.querySelector("select") + } + + usernameField() { + return this.usernameTarget.querySelector("input") + } + + gemname() { + this.usernameTarget.classList.add("hidden") + + this.gemnameField().disabled = false + this.gemnameTarget.classList.remove("hidden") + this.revealTarget.classList.remove("hidden") + + const inputElement = this.gemnameField() + inputElement.focus() + this.updateDisplaynameWith(inputElement.value) + if (inputElement.value === "") { + this.submitTarget.disabled = true + } + this.validate() + } + + username() { + this.gemnameTarget.classList.add("hidden") + this.gemnameField().disabled = true + + this.usernameTarget.classList.remove("hidden") + this.revealTarget.classList.remove("hidden") + + this.updateDisplaynameWith(this.usernameField().value) + this.validate() + } + + validate() { + if (this.element.checkValidity()) { + this.submitTarget.disabled = false + } else { + this.submitTarget.disabled = true + } + } + + updateDisplayname(e) { + this.updateDisplaynameWith(e.currentTarget.value) + this.validate() + } + + updateDisplaynameWith(value) { + // Replace dashes and underscores with spaces. Capitalize the first letter of each word. + this.displaynameTarget.value = value.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()) + } +} diff --git a/app/javascript/controllers/radio_reveal_controller.js b/app/javascript/controllers/radio_reveal_controller.js new file mode 100644 index 00000000000..88b984f40d1 --- /dev/null +++ b/app/javascript/controllers/radio_reveal_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "radio", + "item", + ] + + connect() { + this.update() + } + + update() { + this.itemTargets.forEach(item => { + item.classList.add("hidden") + }) + + this.radioTargets.forEach(radio => { + if (radio.checked) { + const item = this.itemTargets.find(item => item.dataset.name == radio.value) + item.classList.remove("hidden") + } + }) + } +} diff --git a/app/javascript/controllers/recovery_controller.js b/app/javascript/controllers/recovery_controller.js new file mode 100644 index 00000000000..832bd691bf1 --- /dev/null +++ b/app/javascript/controllers/recovery_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + confirm: { type: String, default: "Leave without copying recovery codes?" } + } + + connect() { + this.copied = false; + window.addEventListener("beforeunload", this.popUp); + } + + popUp(e) { + e.preventDefault(); + e.returnValue = ""; + } + + copy() { + if (!this.copied) { + this.copied = true; + window.removeEventListener("beforeunload", this.popUp); + } + } + + submit(e) { + e.preventDefault(); + + if (!this.element.checkValidity()) { + this.element.reportValidity(); + return; + } + + if (this.copied || confirm(this.confirmValue)) { + window.removeEventListener("beforeunload", this.popUp); + // Don't include the form data in the URL. + window.location.href = this.element.action; + } + } +} diff --git a/app/javascript/controllers/reveal_controller.js b/app/javascript/controllers/reveal_controller.js new file mode 100644 index 00000000000..42abc59f821 --- /dev/null +++ b/app/javascript/controllers/reveal_controller.js @@ -0,0 +1,46 @@ +import Reveal from '@stimulus-components/reveal' + +export default class extends Reveal { + static targets = ["item", "toggle", "button"] + static classes = ["hidden", "toggle"] + + connect() { + super.connect() + if (this.hasButtonTarget) { + this.buttonTarget.ariaExpanded = false + } + } + + toggle() { + super.toggle() + if (this.hasButtonTarget) { + this.setAriaExpanded(this.buttonTarget.ariaExpanded === "true" ? "false" : "true") + } + if (this.hasToggleTarget && this.hasToggleClass) { + this.toggleClasses.forEach((className) => { + this.toggleTarget.classList.toggle(className) + }) + } + } + + show() { + super.show() + if (this.hasToggleTarget && this.hasToggleClass) { + this.toggleTarget.classList.add(...this.toggleClasses) + } + } + + hide() { + super.hide() + this.setAriaExpanded('false') + if (this.hasToggleTarget && this.hasToggleClass) { + this.toggleTarget.classList.add(...this.toggleClasses) + } + } + + setAriaExpanded(expanded) { + if (this.hasButtonTarget) { + this.buttonTarget.setAttribute('aria-expanded', expanded) + } + } +} diff --git a/app/javascript/controllers/reveal_search_controller.js b/app/javascript/controllers/reveal_search_controller.js new file mode 100644 index 00000000000..fb3a3ea3356 --- /dev/null +++ b/app/javascript/controllers/reveal_search_controller.js @@ -0,0 +1,19 @@ +import Reveal from 'controllers/reveal_controller' + +export default class extends Reveal { + static targets = ["item", "toggle", "button", "input"] + + // There's nothing here because this is just a copy of the reveal controller + // with a different name. This saves us from a name conflict in the header. + toggle() { + super.toggle() + if (!this.itemTarget.classList.contains("hidden")) { + this.inputTarget.focus() // Auto focus the input when revealed + } + } + + open() { + super.open() + this.inputTarget.focus() + } +} diff --git a/app/javascript/controllers/scroll_controller.js b/app/javascript/controllers/scroll_controller.js new file mode 100644 index 00000000000..f3b76f9ee91 --- /dev/null +++ b/app/javascript/controllers/scroll_controller.js @@ -0,0 +1,19 @@ +/* A controller that, when loaded, causes the page to refresh periodically */ + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["scroll", "scrollLeft"] + + scrollLeftTargetConnected() { + this.element.scrollLeft = this.scrollLeftTarget.offsetLeft + this.scrollLeftTarget.offsetWidth / 2 - this.element.offsetWidth / 2 + } + + scrollTargetConnected() { + this.scrollTarget.scrollIntoView({ behavior: "smooth" }) + } + + scroll(e) { + e.currentTarget.scrollIntoView({ behavior: "smooth" }) + } +} diff --git a/app/javascript/controllers/search_controller.js b/app/javascript/controllers/search_controller.js new file mode 100644 index 00000000000..c8a9a2bbcff --- /dev/null +++ b/app/javascript/controllers/search_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ "query", "attribute" ] + + input(e) { + this.queryTarget.value = this.attributeTargets.map(field => + field.value.length > 0 && field.checkValidity() ? `${field.name}: ${field.value}` : '' + ).join(' ') + } + + submit() { + this.queryTarget.form.submit() + } +} diff --git a/app/javascript/controllers/stats_controller.js b/app/javascript/controllers/stats_controller.js new file mode 100644 index 00000000000..0f17d3f2d66 --- /dev/null +++ b/app/javascript/controllers/stats_controller.js @@ -0,0 +1,10 @@ +import { Controller } from "@hotwired/stimulus" +import $ from 'jquery' + +export default class extends Controller { + static values = { width: String } + + connect() { + $(this.element).animate({ width: this.widthValue + '%' }, 700).removeClass('t-item--hidden').css("display", "block"); + } +} diff --git a/app/javascript/src/clipboard_buttons.js b/app/javascript/src/clipboard_buttons.js deleted file mode 100644 index 23dcd50b1fe..00000000000 --- a/app/javascript/src/clipboard_buttons.js +++ /dev/null @@ -1,38 +0,0 @@ -import $ from "jquery"; -import ClipboardJS from "clipboard"; - -$(function() { - var clipboard = new ClipboardJS('.gem__code__icon'); - var copyTooltip = $('.gem__code__tooltip--copy'); - var copiedTooltip = $('.gem__code__tooltip--copied'); - var copyButtons = $('.gem__code__icon'); - - function hideCopyShowCopiedTooltips(e) { - copyTooltip.removeClass("clipboard-is-hover"); - copiedTooltip.insertAfter(e.trigger); - copiedTooltip.addClass("clipboard-is-active"); - }; - - clipboard.on('success', function(e) { - hideCopyShowCopiedTooltips(e); - e.clearSelection(); - }); - - clipboard.on('error', function(e) { - hideCopyShowCopiedTooltips(e); - copiedTooltip.text("Ctrl-C to Copy"); - }); - - copyButtons.hover(function() { - copyTooltip.insertAfter(this); - copyTooltip.addClass("clipboard-is-hover"); - }); - - copyButtons.mouseout(function() { - copyTooltip.removeClass("clipboard-is-hover"); - }); - - copyButtons.mouseout(function() { - copiedTooltip.removeClass("clipboard-is-active"); - }); -}); diff --git a/app/javascript/src/multifactor_auths.js b/app/javascript/src/multifactor_auths.js deleted file mode 100644 index 365d2db89e4..00000000000 --- a/app/javascript/src/multifactor_auths.js +++ /dev/null @@ -1,43 +0,0 @@ -import $ from "jquery"; -import ClipboardJS from "clipboard"; - -function popUp (e) { - e.preventDefault(); - e.returnValue = ""; -}; - -function confirmNoRecoveryCopy (e, from) { - if (from == null){ - e.preventDefault(); - if (confirm("Leave without copying recovery codes?")) { - window.removeEventListener("beforeunload", popUp); - $(this).trigger('click', ["non-null"]); - } - } -} - -if($("#recovery-code-list").length){ - new ClipboardJS(".recovery__copy__icon"); - - $(".recovery__copy__icon").on("click", function(e){ - $(this).text("[ copied ]"); - - if( !$(this).is(".clicked") ) { - e.preventDefault(); - $(this).addClass("clicked"); - window.removeEventListener("beforeunload", popUp); - $(".form__submit").unbind("click", confirmNoRecoveryCopy); - } - }); - - window.addEventListener("beforeunload", popUp); - $(".form__submit").on("click", confirmNoRecoveryCopy); - - $(".form__checkbox__input").change(function() { - if(this.checked) { - $(".form__submit").prop('disabled', false); - } else { - $(".form__submit").prop('disabled', true); - } - }); -} diff --git a/app/javascript/src/pages.js b/app/javascript/src/pages.js index f7fe0aa7289..301e599cbba 100644 --- a/app/javascript/src/pages.js +++ b/app/javascript/src/pages.js @@ -1,73 +1,5 @@ import $ from "jquery"; -//data page -$(function() { - var getDumpData = function(target, type) { - return $.get('https://s3-us-west-2.amazonaws.com/rubygems-dumps/?prefix=production/public_' + type).done(function(data) { - var files, xml; - xml = $(data); - files = parseS3Listing(xml); - files = sortByLastModified(files); - $(target).html(renderDumpList(files)); - }).fail(function(error) { - console.error(error); - }); - }; - - var parseS3Listing = function(xml) { - var files; - files = $.map(xml.find('Contents'), function(item) { - item = $(item); - return { - Key: item.find('Key').text(), - LastModified: item.find('LastModified').text(), - Size: item.find('Size').text(), - StorageClass: item.find('StorageClass').text() - }; - }); - return files; - }; - - var sortByLastModified = function(files) { - return files.sort(function(a, b) {return Date.parse(b.LastModified) - Date.parse(a.LastModified)}); - }; - - var bytesToSize = function(bytes) { - var i, k, sizes; - if (bytes === 0) { - return '0 Byte'; - } - k = 1024; - sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - i = Math.floor(Math.log(bytes) / Math.log(k)); - return (bytes / Math.pow(k, i)).toPrecision(3) + " " + sizes[i]; - }; - - var renderDumpList = function(files) { - var content; - content = []; - $.each(files, function(idx, item) { - if ('STANDARD' === item.StorageClass) { - return content.push("
  • " + (item.LastModified.replace('.000Z', '')) + " (" + (bytesToSize(item.Size)) + ")
  • "); - } - }); - return content.join("\n"); - }; - - if($("#data-dump").length) { - getDumpData('ul.rubygems-dump-listing-postgresql', 'postgresql'); - getDumpData('ul.rubygems-dump-listing-redis', 'redis'); - } - -}); - -//stats page -$(function() { - $('.stats__graph__gem__meter').each(function() { - $(this).animate({ width: $(this).data("bar-width") + '%' }, 700).removeClass('t-item--hidden').css("display", "block"); - }); -}); - //gem page $(function() { $('.gem__users__mfa-text.mfa-warn').on('click', function() { diff --git a/app/javascript/src/search.js b/app/javascript/src/search.js deleted file mode 100644 index d9ddae734bf..00000000000 --- a/app/javascript/src/search.js +++ /dev/null @@ -1,28 +0,0 @@ -import $ from "jquery"; - -if($("#advanced-search").length){ - var $main = $('#query'); - var $name = $('input#name'); - var $summary = $('input#summary'); - var $description = $('input#description'); - var $downloads = $('input#downloads'); - var $updated = $('input#updated'); - - $name.add($summary) - .add($description) - .add($downloads) - .add($updated) - .on('input', function(e) { - var name = $name.val().length > 0 ? 'name: ' + $name.val() : ''; - var summary = $summary.val().length > 0 ? 'summary: ' + $summary.val() : ''; - var description = $description.val().length > 0 ? 'description: ' + $description.val() : ''; - var downloads = $downloads.val().length > 0 ? 'downloads: ' + $downloads.val() : ''; - var updated = $updated.val().length > 0 ? 'updated: ' + $updated.val() : ''; - - $main.val($.trim(name + ' ' + summary + ' ' + description + ' ' + downloads + ' ' + updated)); - }).on('keypress', function(e) { - if (e.key === 'Enter') { - $("input#advanced_search_submit").click(); - } - }); -} diff --git a/app/jobs/notify_web_hook_job.rb b/app/jobs/notify_web_hook_job.rb index 30543a30147..1f1de9abac0 100644 --- a/app/jobs/notify_web_hook_job.rb +++ b/app/jobs/notify_web_hook_job.rb @@ -13,6 +13,15 @@ class NotifyWebHookJob < ApplicationJob before_perform { @host_with_port = @kwargs.fetch(:host_with_port) } before_perform { @version = @kwargs.fetch(:version) } before_perform { @rubygem = @version.rubygem } + before_perform { @poll_delivery = @kwargs.fetch(:poll_delivery, false) } + before_perform do + @http = Faraday.new("https://api.hookrelay.dev", request: { timeout: TIMEOUT_SEC }) do |f| + f.request :json + f.response :logger, logger, headers: false, errors: true + f.response :json + f.response :raise_error + end + end attr_reader :webhook, :protocol, :host_with_port, :version, :rubygem @@ -21,7 +30,6 @@ class NotifyWebHookJob < ApplicationJob # has to come after the retry on discard_on(Faraday::UnprocessableEntityError) do |j, e| - raise unless j.use_hook_relay? logger.info({ webhook_id: j.webhook.id, url: j.webhook.url, response: JSON.parse(e.response_body) }) j.webhook.increment! :failure_count end @@ -31,11 +39,7 @@ def perform(*) set_tag "gemcutter.notifier.url", url set_tag "gemcutter.notifier.webhook_id", webhook.id - if use_hook_relay? - post_hook_relay - else - post_directly - end + post_hook_relay end statsd_count_success :perform, "Webhook.perform" @@ -48,44 +52,53 @@ def authorization end def hook_relay_url - "https://api.hookrelay.dev/hooks/#{ENV['HOOK_RELAY_ACCOUNT_ID']}/#{ENV['HOOK_RELAY_HOOK_ID']}/webhook_id-#{webhook.id}" - end - - def use_hook_relay? - # can't use hook relay for `perform_now` (aka an unenqueued job) - # because then we won't actually hit the webhook URL synchronously - enqueued_at.present? && ENV["HOOK_RELAY_ACCOUNT_ID"].present? && ENV["HOOK_RELAY_HOOK_ID"].present? + "https://api.hookrelay.dev/hooks/#{account_id}/#{hook_id}/webhook_id-#{webhook.id || 'fire'}" end def post_hook_relay response = post(hook_relay_url) - delivery_id = JSON.parse(response.body).fetch("id") + delivery_id = response.body.fetch("id") Rails.logger.info do { webhook_id: webhook.id, url: webhook.url, delivery_id:, full_name: version.full_name, message: "Sent webhook to HookRelay" } end + return poll_delivery(delivery_id) if @poll_delivery true end - def post_directly - post(webhook.url) - true - rescue *ERRORS - webhook.increment! :failure_count - false - end - def post(url) - Faraday.new(nil, request: { timeout: TIMEOUT_SEC }) do |f| - f.request :json - f.response :logger, logger, headers: false, errors: true - f.response :raise_error - end.post( + @http.post( url, payload, { "Authorization" => authorization, "HR_TARGET_URL" => webhook.url, - "HR_MAX_ATTEMPTS" => "3" + "HR_MAX_ATTEMPTS" => @poll_delivery ? "1" : "3" } ) end + + def poll_delivery(delivery_id) + deadline = (Rails.env.test? ? 0.01 : 10).seconds.from_now + response = nil + until Time.zone.now > deadline + sleep 0.5 + response = @http.get("https://app.hookrelay.dev/api/v1/accounts/#{account_id}/hooks/#{hook_id}/deliveries/#{delivery_id}", nil, { + "Authorization" => "Bearer #{ENV['HOOK_RELAY_API_KEY']}" + }) + status = response.body.fetch("status") + + break if status == "success" + end + + response.body || raise("Failed to get delivery status after 10 seconds") + end + + private + + def account_id + ENV["HOOK_RELAY_ACCOUNT_ID"] + end + + def hook_id + ENV["HOOK_RELAY_HOOK_ID"] + end end diff --git a/app/jobs/refresh_oidc_provider_job.rb b/app/jobs/refresh_oidc_provider_job.rb index 166a5319b8b..e7edcb433cd 100644 --- a/app/jobs/refresh_oidc_provider_job.rb +++ b/app/jobs/refresh_oidc_provider_job.rb @@ -4,6 +4,8 @@ class RefreshOIDCProviderJob < ApplicationJob ERRORS = (HTTP_ERRORS + [Faraday::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError]).freeze retry_on(*ERRORS) + class JWKSURIMismatchError < StandardError; end + def perform(provider:) connection = Faraday.new(provider.issuer, request: { timeout: 2 }, headers: { "Accept" => "application/json" }) do |f| f.request :json @@ -15,6 +17,9 @@ def perform(provider:) provider.configuration = resp.body provider.configuration.validate! + if provider.configuration.jwks_uri.blank? || URI.parse(provider.configuration.jwks_uri).host != URI.parse(provider.issuer).host + raise JWKSURIMismatchError, "Invalid JWKS URI in OpenID Connect configuration #{provider.configuration.jwks_uri.inspect}" + end provider.jwks = connection.get(provider.configuration.jwks_uri).body provider.save! diff --git a/app/jobs/rstuf/check_job.rb b/app/jobs/rstuf/check_job.rb index c66da399fe2..82e2bc1b7c8 100644 --- a/app/jobs/rstuf/check_job.rb +++ b/app/jobs/rstuf/check_job.rb @@ -14,7 +14,7 @@ def perform(task_id) raise FailureException, "RSTUF job failed, please check payload and retry" when "ERRORED", "REVOKED", "REJECTED" raise ErrorException, "RSTUF internal problem, please check RSTUF health" - when "PENDING", "RUNNING", "RECEIVED", "STARTED" + when "PENDING", "PRE_RUN", "RUNNING", "RECEIVED", "STARTED" raise RetryException else Rails.logger.info "RSTUF job returned unexpected state #{status}" diff --git a/app/jobs/verify_link_job.rb b/app/jobs/verify_link_job.rb index 50667b6b0b5..8a18a838cf1 100644 --- a/app/jobs/verify_link_job.rb +++ b/app/jobs/verify_link_job.rb @@ -9,10 +9,6 @@ class NotHTTPSError < StandardError; end class LinkNotPresentError < StandardError; end class HTTPResponseError < StandardError; end - rescue_from NotHTTPSError do |_error| - record_failure - end - rescue_from LinkNotPresentError, HTTPResponseError, *ERRORS do |error| logger.info "Linkback verification failed with error: #{error.message}", error: error, uri: link_verification.uri, linkable: link_verification.linkable.to_gid @@ -23,6 +19,10 @@ class HTTPResponseError < StandardError; end end end + rescue_from NotHTTPSError, Faraday::RestrictIPAddresses::AddressNotAllowed do |_error| + record_failure + end + TIMEOUT_SEC = 5 def perform(link_verification:) @@ -68,6 +68,9 @@ def verify_link!(uri, linkable) def get(url) Faraday.new(nil, request: { timeout: TIMEOUT_SEC }) do |f| + # prevent SSRF attacks + f.request :restrict_ip_addresses, deny_rfc6890: true + f.response :logger, logger, headers: false, errors: true f.response :raise_error end.get( diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb index d9ee97a0db8..98bda5678fe 100644 --- a/app/mailers/mailer.rb +++ b/app/mailers/mailer.rb @@ -30,6 +30,17 @@ def email_confirmation(user) end end + def admin_manual(user, subject, body) + @user = user + @body = body + @sub_title = subject + mail to: @user.email, + subject: subject do |format| + format.html + format.text + end + end + def deletion_complete(email) mail to: email, subject: I18n.t("mailer.deletion_complete.subject") diff --git a/app/mailers/owners_mailer.rb b/app/mailers/owners_mailer.rb index 7b6bef127c1..2130ee0abcb 100644 --- a/app/mailers/owners_mailer.rb +++ b/app/mailers/owners_mailer.rb @@ -13,6 +13,17 @@ def ownership_confirmation(ownership) end end + def owner_updated + @ownership = params[:ownership] + @user = @ownership.user + @rubygem = @ownership.rubygem + + mail( + to: @user.email, + subject: t("mailer.owner_updated.subject", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY) + ) + end + def owner_removed(user_id, remover_id, gem_id) @user = User.find(user_id) @remover = User.find(remover_id) diff --git a/app/models/api_key.rb b/app/models/api_key.rb index d0a4c113ad0..924bf5fc3f2 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -1,9 +1,9 @@ class ApiKey < ApplicationRecord class ScopeError < RuntimeError; end - API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks + API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner update_owner remove_owner access_webhooks configure_trusted_publishers].freeze - APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner configure_trusted_publishers].freeze + APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner update_owner remove_owner configure_trusted_publishers].freeze EXCLUSIVE_SCOPES = %i[show_dashboard].freeze self.ignored_columns += API_SCOPES @@ -20,10 +20,10 @@ class ScopeError < RuntimeError; end after_create :record_create_event after_update :record_expire_event, if: :saved_change_to_expires_at? - validates :name, :hashed_key, presence: true validate :exclusive_show_dashboard_scope, if: :can_show_dashboard? validate :scope_presence - validates :name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validates :name, presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validates :hashed_key, presence: true, uniqueness: true validates :expires_at, inclusion: { in: -> { 1.minute.from_now.. } }, allow_nil: true, on: :create validate :rubygem_scope_definition, if: :ownership validate :known_scopes @@ -40,7 +40,7 @@ class ScopeError < RuntimeError; end def self.expire_all! transaction do - find_each.all?(&:expire!) + unexpired.find_each.all?(&:expire!) end end @@ -70,6 +70,10 @@ def user? owner_type == "User" end + def trusted_publisher? + owner_type.deconstantize == "OIDC::TrustedPublisher" + end + delegate :mfa_required_not_yet_enabled?, :mfa_required_weak_level_enabled?, :mfa_recommended_not_yet_enabled?, :mfa_recommended_weak_level_enabled?, to: :user, allow_nil: true diff --git a/app/models/attestation.rb b/app/models/attestation.rb new file mode 100644 index 00000000000..e85eac9e770 --- /dev/null +++ b/app/models/attestation.rb @@ -0,0 +1,50 @@ +class Attestation < ApplicationRecord + belongs_to :version + + validates :body, :media_type, presence: true + attribute :body, :jsonb + + def sigstore_bundle + Sigstore::SBundle.new( + Sigstore::Bundle::V1::Bundle.decode_json_hash(body, registry: Sigstore::REGISTRY) + ) + end + + def display_data # rubocop:disable Metrics/MethodLength + bundle = sigstore_bundle + leaf_certificate = bundle.leaf_certificate + + issuer = leaf_certificate.extension(Sigstore::Internal::X509::Extension::FulcioIssuer).issuer + log_index = bundle.verification_material.tlog_entries.first.log_index + + extensions = leaf_certificate.openssl.extensions.to_h do |ext| + [ext.oid, if (ext.oid =~ /\A1\.3\.6\.1\.4\.1\.57264\.1\.(\d+)\z/) && ::Regexp.last_match(1).to_i >= 8 + OpenSSL::ASN1.decode(ext.value_der).value + else + ext.value + end] + end + + repo = extensions["1.3.6.1.4.1.57264.1.5"] + commit = extensions["1.3.6.1.4.1.57264.1.3"] + ref = extensions["1.3.6.1.4.1.57264.1.14"] + san = extensions["subjectAltName"] + build_file_url = extensions["1.3.6.1.4.1.57264.1.21"] + + case issuer + when "https://token.actions.githubusercontent.com" + san =~ %r{\AURI:https://github\.com/#{Regexp.escape(repo)}/(.+)@#{Regexp.escape(ref)}\z} + build_file_string = ::Regexp.last_match(1) + { + ci_platform: "GitHub Actions", + source_commit_string: "github.com/#{repo}@#{commit[0, 7]}", + source_commit_url: "https://github.com/#{repo}/tree/#{commit}", + build_file_string:, build_file_url: + } + else + raise "Unhandled issuer: #{issuer.inspect}" + end.merge( + log_index: + ) + end +end diff --git a/app/models/concerns/rubygem_searchable.rb b/app/models/concerns/rubygem_searchable.rb index d0d5c4737c4..27eee6ad34f 100644 --- a/app/models/concerns/rubygem_searchable.rb +++ b/app/models/concerns/rubygem_searchable.rb @@ -24,7 +24,7 @@ module RubygemSearchable description: { type: "text", analyzer: "english", fields: { raw: { analyzer: "simple", type: "text" } } }, suggest: { type: "completion", contexts: { name: "yanked", type: "category" } }, yanked: { type: "boolean" }, - downloads: { type: "integer" }, + downloads: { type: "long" }, updated: { type: "date" } } } @@ -88,12 +88,16 @@ def suggest_json { suggest: { input: name, - weight: downloads, + weight: suggest_weight_scale(downloads), contexts: { yanked: versions.none?(&:indexed?) } } } end + + def suggest_weight_scale(downloads) + Math.log10(downloads + 1).to_i + end end end diff --git a/app/models/concerns/user_multifactor_methods.rb b/app/models/concerns/user_multifactor_methods.rb index 2d181787f3e..07a687e532e 100644 --- a/app/models/concerns/user_multifactor_methods.rb +++ b/app/models/concerns/user_multifactor_methods.rb @@ -7,7 +7,7 @@ module UserMultifactorMethods attr_accessor :new_mfa_recovery_codes - enum mfa_level: { disabled: 0, ui_only: 1, ui_and_api: 2, ui_and_gem_signin: 3 }, _prefix: :mfa + enum :mfa_level, { disabled: 0, ui_only: 1, ui_and_api: 2, ui_and_gem_signin: 3 }, prefix: :mfa validate :mfa_level_for_enabled_devices end diff --git a/app/models/events/organization_event.rb b/app/models/events/organization_event.rb new file mode 100644 index 00000000000..8171c3738aa --- /dev/null +++ b/app/models/events/organization_event.rb @@ -0,0 +1,10 @@ +class Events::OrganizationEvent < ApplicationRecord + belongs_to :organization + + include Events::Tags + + CREATED = define_event "organization:created" do + attribute :name, :string + attribute :actor_gid, :global_id + end +end diff --git a/app/models/events/rubygem_event.rb b/app/models/events/rubygem_event.rb index 11c7dea8b13..6f4895dd442 100644 --- a/app/models/events/rubygem_event.rb +++ b/app/models/events/rubygem_event.rb @@ -59,6 +59,17 @@ class Events::RubygemEvent < ApplicationRecord attribute :owner_gid, :global_id end + OWNER_ROLE_UPDATED = define_event "rubygem:owner:role_updated" do + attribute :owner, :string + attribute :updated_by, :string + + attribute :actor_gid, :global_id + attribute :owner_gid, :global_id + + attribute :previous_role, :string + attribute :current_role, :string + end + OWNER_REMOVED = define_event "rubygem:owner:removed" do attribute :owner, :string attribute :removed_by, :string diff --git a/app/models/gem_name_reservation.rb b/app/models/gem_name_reservation.rb index 4e3e69eb566..cb4e685a20a 100644 --- a/app/models/gem_name_reservation.rb +++ b/app/models/gem_name_reservation.rb @@ -1,5 +1,5 @@ class GemNameReservation < ApplicationRecord - validates :name, uniqueness: { case_sensitive: false }, presence: true + validates :name, uniqueness: { case_sensitive: false }, presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } validate :downcase_name_check def self.reserved?(name) diff --git a/app/models/gem_typo_exception.rb b/app/models/gem_typo_exception.rb index 98db3b92cde..668c5e3830b 100644 --- a/app/models/gem_typo_exception.rb +++ b/app/models/gem_typo_exception.rb @@ -1,5 +1,5 @@ class GemTypoException < ApplicationRecord - validates :name, presence: true, uniqueness: { case_sensitive: false } + validates :name, presence: true, uniqueness: { case_sensitive: false }, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } validate :rubygems_name private diff --git a/app/models/linkset.rb b/app/models/linkset.rb index dc0be7a6d3d..ccea8016728 100644 --- a/app/models/linkset.rb +++ b/app/models/linkset.rb @@ -11,6 +11,8 @@ class Linkset < ApplicationRecord allow_nil: true, allow_blank: true, message: "does not appear to be a valid URL" + + validates url, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } end def empty? diff --git a/app/models/log_ticket.rb b/app/models/log_ticket.rb index 2d6c8c7c85e..51b3ac47ba9 100644 --- a/app/models/log_ticket.rb +++ b/app/models/log_ticket.rb @@ -1,6 +1,6 @@ class LogTicket < ApplicationRecord - enum backend: { s3: 0, local: 1 } - enum status: %i[pending processing failed processed].index_with(&:to_s) + enum :backend, { s3: 0, local: 1 } + enum :status, %i[pending processing failed processed].index_with(&:to_s) def self.pop(key: nil, directory: nil) scope = pending.limit(1).lock(true).order("id ASC") diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 00000000000..f570f99bbbf --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,15 @@ +class Membership < ApplicationRecord + belongs_to :user + belongs_to :organization + + scope :unconfirmed, -> { where(confirmed_at: nil) } + scope :confirmed, -> { where.not(confirmed_at: nil) } + + enum :role, { owner: Access::OWNER, admin: Access::ADMIN, maintainer: Access::MAINTAINER }, validate: true, default: :maintainer + + scope :with_minimum_role, ->(role) { where(role: Access.flag_for_role(role)...) } + + def confirmed? + !confirmed_at.nil? + end +end diff --git a/app/models/oidc/api_key_role.rb b/app/models/oidc/api_key_role.rb index 1f7edf17a85..9111cc723e6 100644 --- a/app/models/oidc/api_key_role.rb +++ b/app/models/oidc/api_key_role.rb @@ -22,7 +22,7 @@ class OIDC::ApiKeyRole < ApplicationRecord scope :deleted, -> { where.not(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) } - validates :name, presence: true, length: { maximum: 255 }, uniqueness: { scope: :user_id } + validates :name, presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, uniqueness: { scope: :user_id } attribute :api_key_permissions, Types::JsonDeserializable.new(OIDC::ApiKeyPermissions) validates :api_key_permissions, presence: true, nested: true diff --git a/app/models/oidc/trusted_publisher/github_action.rb b/app/models/oidc/trusted_publisher/github_action.rb index dd68c8132ec..dfff396e0d8 100644 --- a/app/models/oidc/trusted_publisher/github_action.rb +++ b/app/models/oidc/trusted_publisher/github_action.rb @@ -8,11 +8,9 @@ class OIDC::TrustedPublisher::GitHubAction < ApplicationRecord before_validation :find_github_repository_owner_id - validates :repository_owner, presence: true - validates :repository_name, presence: true - validates :workflow_filename, presence: true - validates :environment, presence: true, allow_nil: true - validates :repository_owner_id, presence: true + validates :repository_owner, :repository_name, :workflow_filename, :repository_owner_id, + presence: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } + validates :environment, presence: true, allow_nil: true, length: { maximum: Gemcutter::MAX_FIELD_LENGTH } validate :unique_publisher validate :workflow_filename_format @@ -121,6 +119,24 @@ def to_access_policy(jwt) ) end + class SigstorePolicy + def initialize(trusted_publisher) + @trusted_publisher = trusted_publisher + end + + def verify(cert) + ref = cert.openssl.find_extension("1.3.6.1.4.1.57264.1.14")&.value_der&.then { OpenSSL::ASN1.decode(_1).value } + Sigstore::Policy::Identity.new( + identity: "https://github.com/#{@trusted_publisher.repository}/#{@trusted_publisher.workflow_slug}@#{ref}", + issuer: OIDC::Provider::GITHUB_ACTIONS_ISSUER + ).verify(cert) + end + end + + def to_sigstore_identity_policy + SigstorePolicy.new(self) + end + def name name = "#{self.class.publisher_name} #{repository_owner}/#{repository_name} @ #{workflow_slug}" name << " (#{environment})" if environment? diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 00000000000..1e6560d26a9 --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,23 @@ +class Organization < ApplicationRecord + include Events::Recordable + + validates :handle, presence: true, + uniqueness: { case_sensitive: false }, + length: { within: 2..40 }, + format: { with: Patterns::HANDLE_PATTERN } + validates :name, presence: true, length: { within: 2..255 } + + has_many :memberships, -> { where.not(confirmed_at: nil) }, dependent: :destroy, inverse_of: :organization + has_many :unconfirmed_memberships, -> { where(confirmed_at: nil) }, class_name: "Membership", dependent: :destroy, inverse_of: :organization + has_many :users, through: :memberships + has_many :rubygems, dependent: :nullify + has_one :organization_onboarding, foreign_key: :onboarded_organization_id, inverse_of: :organization, dependent: :destroy + + default_scope { not_deleted } + scope :not_deleted, -> { where(deleted_at: nil) } + scope :deleted, -> { unscoped.where.not(deleted_at: nil) } + + after_create do + record_event!(Events::OrganizationEvent::CREATED, actor_gid: memberships.first&.to_gid) + end +end diff --git a/app/models/organization_onboarding.rb b/app/models/organization_onboarding.rb new file mode 100644 index 00000000000..d97aaacc95b --- /dev/null +++ b/app/models/organization_onboarding.rb @@ -0,0 +1,163 @@ +class OrganizationOnboarding < ApplicationRecord + enum :name_type, { gem: "gem", user: "user" }, prefix: true, default: "gem" + enum :status, { pending: "pending", completed: "completed", failed: "failed" }, default: "pending" + + has_many :invites, -> { preload(:user) }, class_name: "OrganizationOnboardingInvite", inverse_of: :organization_onboarding, dependent: :destroy + has_many :users, through: :invites + belongs_to :organization, optional: true, foreign_key: :onboarded_organization_id, inverse_of: :organization_onboarding + belongs_to :created_by, class_name: "User", inverse_of: :organization_onboardings + + accepts_nested_attributes_for :invites + + validate :created_by_gem_ownerships + + validates :organization_name, :organization_handle, :name_type, presence: true + + with_options if: :completed? do + validates :onboarded_at, presence: true + end + + with_options if: :failed? do + validates :error, presence: true + end + + with_options if: :name_type_user? do + before_validation :set_user_handle + end + + with_options if: :name_type_gem? do + validate :organization_handle_matches_rubygem_name + after_validation :add_namesake_rubygem + end + + before_validation :remove_invalid_invites + before_save :sync_invites, if: :rubygems_changed? + + def onboard! + raise StandardError, "onboard has already been completed" if completed? + + transaction do + onboarded_organization = create_organization! + onboard_rubygems!(onboarded_organization) + + update!( + onboarded_at: Time.zone.now, + status: :completed, + onboarded_organization_id: onboarded_organization.id + ) + end + + remove_ownerships + rescue ActiveRecord::ActiveRecordError => e + self.status = :failed + self.error = e.message + save(validate: false) + + raise e + end + + def available_rubygems + return Rubygem.none if created_by.blank? + created_by.rubygems.where(organization_id: nil).order(:name) + end + + def selected_rubygems + Rubygem.where(id: rubygems).all + end + + def namesake_rubygem + return unless name_type_gem? + created_by&.rubygems&.find_by(name: organization_handle) + end + + def approved_invites + invites.select { |invite| invite.user.present? && invite.role.present? } + end + + def rubygems=(value) + super(Rubygem.where(id: value.compact_blank, organization_id: nil).pluck(:id)) + end + + private + + def users_for_selected_gems + return User.none if available_rubygems.blank? || created_by.blank? + User + .joins(:ownerships) + .where(ownerships: { rubygem_id: available_rubygems.pluck(:id) }) + .where.not(ownerships: { user_id: created_by }) + .order(Arel.sql("COUNT (ownerships.id) DESC")) + .group(users: [:id]) + end + + def sync_invites + existing_invites = invites.index_by(&:user_id) + self.invites = users_for_selected_gems.map { existing_invites[_1.id] || OrganizationOnboardingInvite.new(user: _1) } + end + + def remove_invalid_invites + self.invites = invites.select(&:valid?) + end + + def create_organization! + memberships = invites.filter_map(&:to_membership) + memberships << build_owner + + Organization.create!( + name: organization_name, + handle: organization_handle, + memberships: memberships + ) + end + + def onboard_rubygems!(onboarded_organization) + onboarding_gems = Rubygem.where(id: rubygems).all + onboarding_gems.each do |rubygem| + rubygem.update!(organization_id: onboarded_organization.id) + end + end + + def build_owner + Membership.build( + user: created_by, + role: :owner, + confirmed_at: Time.zone.now + ) + end + + def set_user_handle + return if created_by.blank? || !name_type_user? + self.organization_handle = created_by.handle + end + + def remove_ownerships + onboarded_users = invites.reject { _1.role.nil? || _1.outside_contributor? }.map(&:user) + onboarded_users << created_by + + Ownership.includes(:rubygem, :user, :api_key_rubygem_scopes).where(user: onboarded_users, rubygem: selected_rubygems).destroy_all + end + + def add_namesake_rubygem + namesake = namesake_rubygem + return unless namesake && rubygems.exclude?(namesake.id) + rubygems.unshift(namesake.id) + end + + def organization_handle_matches_rubygem_name + return if organization_handle.blank? + return if namesake_rubygem.present? + return if selected_rubygems.any? { _1.name == organization_handle } + + errors.add(:organization_handle, "must match a rubygem you own") + end + + def created_by_gem_ownerships + return if created_by.blank? || rubygems.blank? + + ownerships = Ownership.where(user: created_by, rubygem: rubygems).index_by(&:rubygem_id) + + selected_rubygems.reject { ownerships[_1.id].present? && ownerships[_1.id].owner? }.each do |rubygem| + errors.add(:created_by, "must be an owner of the #{rubygem.name} gem") + end + end +end diff --git a/app/models/organization_onboarding_invite.rb b/app/models/organization_onboarding_invite.rb new file mode 100644 index 00000000000..8de20669b50 --- /dev/null +++ b/app/models/organization_onboarding_invite.rb @@ -0,0 +1,16 @@ +class OrganizationOnboardingInvite < ApplicationRecord + belongs_to :organization_onboarding, inverse_of: :invites + belongs_to :user + + validates :user_id, uniqueness: { scope: :organization_onboarding_id } + + enum :role, { owner: "owner", admin: "admin", maintainer: "maintainer", outside_contributor: "outside_contributor" }, + validate: { allow_nil: true } + + def to_membership + Membership.new( + user: user, + role: role + ) + end +end diff --git a/app/models/ownership.rb b/app/models/ownership.rb index fd6e4b94d1a..bbe1f3776c8 100644 --- a/app/models/ownership.rb +++ b/app/models/ownership.rb @@ -13,11 +13,17 @@ class Ownership < ApplicationRecord after_create :record_create_event after_update :record_confirmation_event, if: :saved_change_to_confirmed_at? + after_update :record_role_updated_event, if: :saved_change_to_role? + after_update :notify_user_role_of_role_change, if: :saved_change_to_role? after_destroy :record_destroy_event scope :confirmed, -> { where.not(confirmed_at: nil) } scope :unconfirmed, -> { where(confirmed_at: nil) } + enum :role, { owner: Access::OWNER, maintainer: Access::MAINTAINER }, validate: true, default: :owner + + scope :user_with_minimum_role, ->(user, role) { where(user: user, role: Access.with_minimum_role(role)) } + def self.by_indexed_gem_name select("ownerships.*, rubygems.name") .left_joins(rubygem: :versions) @@ -26,8 +32,8 @@ def self.by_indexed_gem_name .order("rubygems.name ASC") end - def self.find_by_owner_handle!(handle) - joins(:user).find_by(users: { handle: handle }) || joins(:user).find_by!(users: { id: handle }) + def self.find_by_owner_handle(handle) + joins(:user).find_by(users: { handle: handle }) || joins(:user).find_by(users: { id: handle }) end def self.create_confirmed(rubygem, user, approver) @@ -107,6 +113,16 @@ def record_confirmation_event actor_gid: Current.user&.to_gid) end + def record_role_updated_event + rubygem.record_event!(Events::RubygemEvent::OWNER_ROLE_UPDATED, + owner: user.display_handle, + updated_by: Current.user&.display_handle, + owner_gid: user.to_gid, + actor_gid: Current.user&.to_gid, + previous_role: role_previously_was, + current_role: role) + end + def record_destroy_event rubygem.record_event!(Events::RubygemEvent::OWNER_REMOVED, owner: user.display_handle, @@ -114,4 +130,8 @@ def record_destroy_event owner_gid: user.to_gid, actor_gid: Current.user&.to_gid) end + + def notify_user_role_of_role_change + OwnersMailer.with(ownership: self).owner_updated.deliver_later + end end diff --git a/app/models/ownership_call.rb b/app/models/ownership_call.rb index 2973c67994e..01b836dbf77 100644 --- a/app/models/ownership_call.rb +++ b/app/models/ownership_call.rb @@ -10,7 +10,7 @@ class OwnershipCall < ApplicationRecord delegate :name, to: :rubygem, prefix: true delegate :display_handle, to: :user, prefix: true - enum status: { opened: true, closed: false } + enum :status, { opened: true, closed: false } def close! ownership_requests.each(&:close!) diff --git a/app/models/ownership_request.rb b/app/models/ownership_request.rb index 545ce581a30..3aafdd45514 100644 --- a/app/models/ownership_request.rb +++ b/app/models/ownership_request.rb @@ -4,14 +4,15 @@ class OwnershipRequest < ApplicationRecord belongs_to :ownership_call, optional: true belongs_to :approver, class_name: "User", optional: true - validates :rubygem_id, :user_id, :status, :note, presence: true + validates :status, :note, presence: true validates :note, length: { maximum: Gemcutter::MAX_TEXT_FIELD_LENGTH } validates :user_id, uniqueness: { scope: :rubygem_id, conditions: -> { opened } } + validate :not_already_owner, on: :create delegate :name, to: :user, prefix: true delegate :name, to: :rubygem, prefix: true - enum status: { opened: 0, approved: 1, closed: 2 } + enum :status, { opened: 0, approved: 1, closed: 2 } def approve!(approver) return unless Pundit.policy!(approver, self).approve? @@ -32,4 +33,11 @@ def close!(closer = nil) return if closer && closer == user # Don't notify the requester if they closed their own request OwnersMailer.ownership_request_closed(id).deliver_later end + + private + + def not_already_owner + return unless rubygem.owned_by?(user) + errors.add(:user_id, I18n.t("activerecord.errors.models.ownership_request.attributes.user_id.existing")) + end end diff --git a/app/models/pusher.rb b/app/models/pusher.rb index 6b256ce230a..0f0dab75908 100644 --- a/app/models/pusher.rb +++ b/app/models/pusher.rb @@ -4,21 +4,30 @@ class Pusher include TraceTagger include SemanticLogger::Loggable - attr_reader :api_key, :owner, :spec, :spec_contents, :message, :code, :rubygem, :body, :version, :version_id, :size + attr_reader :api_key, :spec, :spec_contents, :message, :code, :rubygem, :body, :version, :version_id, :size, :attestations - def initialize(api_key, body, request: nil) + delegate :owner, to: :api_key + + def initialize(api_key, body, request: nil, attestations: nil) @api_key = api_key - @owner = api_key.owner @scoped_rubygem = api_key.rubygem @body = StringIO.new(body.read) + @attestations = attestations + @size = @body.size @request = request end def process trace("gemcutter.pusher.process", tags: { "gemcutter.api_key.owner" => owner.to_gid }) do - pull_spec && find && authorize && verify_gem_scope && verify_mfa_requirement && validate && save + pull_spec && + find && + authorize && + verify_gem_scope && + verify_mfa_requirement && + validate && + save end end @@ -48,13 +57,18 @@ def validate return notify("There was a problem saving your gem: the uploaded spec has malformed platform attributes", 409) end + return unless verify_sigstore + true end def save # Restructured so that if we fail to write the gem (ie, s3 is down) # can clean things up well. - return notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) unless update + unless update + return false if message + return notify("There was a problem saving your gem: #{rubygem.all_errors(version)}", 403) + end trace("gemcutter.pusher.write_gem") do write_gem @body, @spec_contents end @@ -73,7 +87,9 @@ def save end def pull_spec - package = Gem::Package.new(body, gem_security_policy) + # ensure the body can't be treated as a file path + package_source = Gem::Package::IOSource.new(body) + package = Gem::Package.new(package_source, gem_security_policy) @spec = package.spec @files = package.files validate_spec && serialize_spec @@ -84,13 +100,19 @@ def pull_spec Ensure you are using a recent version of RubyGems to build the gem by running `gem update --system` and then try pushing again. MSG - rescue StandardError => e + rescue Gem::Exception, Psych::DisallowedClass, ArgumentError => e notify <<~MSG, 422 RubyGems.org cannot process this gem. Please try rebuilding it and installing it locally to make sure it's valid. Error: #{e.message} MSG + rescue StandardError + # Ensure arbitrary exceptions are not leaked to the client + notify <<~MSG, 422 + RubyGems.org cannot process this gem. + Please try rebuilding it and installing it locally to make sure it's valid. + MSG end def find # rubocop:disable Metrics/AbcSize, Metrics/MethodLength @@ -143,12 +165,47 @@ def find # rubocop:disable Metrics/AbcSize, Metrics/MethodLength # Overridden so we don't get megabytes of the raw data printing out def inspect - attrs = %i[@rubygem @owner @message @code].map do |attr| - "#{attr}=#{instance_variable_get(attr).inspect}" + attrs = { :@rubygem => @rubygem, :@owner => owner, :@message => @message, :@code => @code }.map do |attr, value| + "#{attr}=#{value.inspect}" end "" end + def verify_sigstore + return true if attestations.blank? + return notify("Pushing with an attestation requires trusted publishing", 400) unless api_key.trusted_publisher? + + policy = api_key.owner.to_sigstore_identity_policy + + artifact = Sigstore::Verification::V1::Artifact.new + artifact.artifact = body.string + + attestations.each do |attestation| + bundle = Sigstore::Bundle::V1::Bundle.decode_json_hash(attestation, registry: Sigstore::REGISTRY) + + verification_input = Sigstore::Verification::V1::Input.new + verification_input.artifact = artifact + verification_input.bundle = bundle + input = Sigstore::VerificationInput.new(verification_input) + sigstore_verification = sigstore_verifier.verify(input:, policy:, offline: true) + logger.info do + { message: "verifying sigstore bundles", sigstore_verification: sigstore_verification, policy: policy } + end + + return notify("Attestation verification failed:\n#{sigstore_verification.reason}", 422) unless sigstore_verification.verified? + return notify("Must provide at least v0.3 bundles", 422) unless input.sbundle.bundle_type >= Sigstore::BundleType::BUNDLE_0_3 + + @version.attestations << Attestation.new(body: bundle, media_type: bundle.media_type) + end + + true + rescue Sigstore::Error => e + notify <<~MSG, 422 + Error verifying sigstore attestation: + #{e.message} + MSG + end + private def after_write @@ -172,18 +229,19 @@ def update rubygem.update_attributes_from_gem_specification!(version, spec) if rubygem.unowned? - case owner - when User + if api_key.user? rubygem.create_ownership(owner) - else + elsif api_key.trusted_publisher? pending_publisher = find_pending_trusted_publisher - return notify_unauthorized if pending_publisher.blank? + return notify("No pending publisher found", 404) if pending_publisher.blank? rubygem.transaction do logger.info { "Reifying pending publisher" } rubygem.create_ownership(pending_publisher.user) owner.rubygem_trusted_publishers.create!(rubygem: rubygem) end + else + return notify_unauthorized end end @@ -303,7 +361,11 @@ def serialize_spec end def find_pending_trusted_publisher - return unless owner.class.module_parent_name == "OIDC::TrustedPublisher" + return unless api_key.trusted_publisher? owner.pending_trusted_publishers.unexpired.rubygem_name_is(rubygem.name).first end + + def sigstore_verifier + @sigstore_verifier ||= Sigstore::Verifier.production + end end diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 2bef507f40b..59529105575 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -1,7 +1,6 @@ class Rubygem < ApplicationRecord include Patterns include RubygemSearchable - include Events::Recordable has_many :ownerships, -> { confirmed }, dependent: :destroy, inverse_of: :rubygem has_many :ownerships_including_unconfirmed, dependent: :destroy, class_name: "Ownership" @@ -27,6 +26,12 @@ class Rubygem < ApplicationRecord has_many :reverse_development_dependencies, -> { merge(Dependency.development) }, through: :incoming_dependencies, source: :version_rubygem has_many :reverse_runtime_dependencies, -> { merge(Dependency.runtime) }, through: :incoming_dependencies, source: :version_rubygem + belongs_to :organization, optional: true + + # needs to come last so its dependent: :destroy works, since yanking a version + # will create an event + include Events::Recordable + has_one :most_recent_version, lambda { order(Arel.sql("case when #{quoted_table_name}.latest AND #{quoted_table_name}.platform = 'ruby' then 2 else 1 end desc")) @@ -173,7 +178,7 @@ def hosted? end def unowned? - ownerships.blank? + ownerships.none? && !owned_by_organization? end def indexed_versions? @@ -182,7 +187,14 @@ def indexed_versions? def owned_by?(user) return false unless user - ownerships.exists?(user_id: user.id) + ownerships.exists?(user_id: user.id) || (owned_by_organization? && user_authorized_for_organization?(user)) + end + + def owned_by_with_role?(user, minimum_required_role) + return false if user.blank? + ownerships.user_with_minimum_role(user, minimum_required_role).exists? + rescue KeyError + false end def unconfirmed_ownerships @@ -420,4 +432,12 @@ def bulk_reorder_versions sanitized_query = ActiveRecord::Base.send(:sanitize_sql_array, update_query) ActiveRecord::Base.connection.execute(sanitized_query) end + + def owned_by_organization? + organization.present? + end + + def user_authorized_for_organization?(user) + organization.memberships.exists?(user: user) + end end diff --git a/app/models/rubygem_contents/entry.rb b/app/models/rubygem_contents/entry.rb index e6a218f27a3..396d205030a 100644 --- a/app/models/rubygem_contents/entry.rb +++ b/app/models/rubygem_contents/entry.rb @@ -3,7 +3,7 @@ class RubygemContents::Entry class InvalidMetadata < RuntimeError; end - SIZE_LIMIT = 500.megabyte + SIZE_LIMIT = 500.megabytes MIME_TEXTUAL_SUBTYPES = %w[json ld+json x-csh x-sh x-httpd-php xhtml+xml xml].freeze class << self diff --git a/app/models/user.rb b/app/models/user.rb index b756fbbae04..8b25cd75531 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -64,25 +64,26 @@ class User < ApplicationRecord has_many :oidc_pending_trusted_publishers, class_name: "OIDC::PendingTrustedPublisher", inverse_of: :user, dependent: :destroy has_many :oidc_rubygem_trusted_publishers, through: :rubygems, class_name: "OIDC::RubygemTrustedPublisher" + has_many :memberships, dependent: :destroy + has_many :organizations, through: :memberships + + has_many :organization_onboardings, foreign_key: :created_by_id, dependent: :nullify, inverse_of: :created_by + validates :email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, -uniqueness: { case_sensitive: false } + uniqueness: { case_sensitive: false } validates :unconfirmed_email, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true validates :handle, uniqueness: { case_sensitive: false }, allow_nil: true, if: :handle_changed? - validates :handle, format: { - with: /\A[A-Za-z][A-Za-z_\-0-9]*\z/, - message: "must start with a letter and can only contain letters, numbers, underscores, and dashes" - }, allow_nil: true - validates :handle, length: { within: 2..40 }, allow_nil: true + validates :handle, format: { with: Patterns::HANDLE_PATTERN }, length: { within: 2..40 }, allow_nil: true + validate :unique_with_org_handle validates :twitter_username, format: { with: /\A[a-zA-Z0-9_]*\z/, message: "can only contain letters, numbers, and underscores" - }, allow_nil: true + }, allow_nil: true, length: { within: 0..20 } - validates :twitter_username, length: { within: 0..20 }, allow_nil: true validates :password, - length: { within: 10..200 }, + length: { minimum: 10 }, unpwn: true, allow_blank: true, # avoid double errors with can't be blank unless: :skip_password_validation? @@ -91,6 +92,7 @@ class User < ApplicationRecord validate :unconfirmed_email_uniqueness validate :toxic_email_domain, on: :create + validate :password_byte_length def self.authenticate(who, password) # Avoid exceptions when string is invalid in the given encoding, _or_ cannot be converted @@ -117,6 +119,11 @@ def self.find_by_slug(slug) find_by(id: slug) || find_by(handle: slug) end + def self.find_by_name!(name) + raise ActiveRecord::RecordNotFound if name.blank? + find_by_email(name) || find_by!(handle: name) + end + def self.find_by_name(name) return if name.blank? find_by_email(name) || find_by(handle: name) @@ -141,8 +148,7 @@ def self.ownership_request_notifiable_owners def self.normalize_email(email) email.to_s.gsub(/\s+/, "") - rescue ArgumentError => e - Rails.error.report(e, handled: true) + rescue ArgumentError "" end @@ -260,6 +266,19 @@ def block! end end + def unblock! + raise ArgumentError, "User is not blocked" unless blocked? + + update!( + email: blocked_email, + blocked_email: nil + ) + end + + def blocked? + blocked_email.present? + end + def owns_gem?(rubygem) rubygem.owned_by?(self) end @@ -305,8 +324,13 @@ def toxic_email_domain errors.add(:email, I18n.t("activerecord.errors.messages.blocked", domain: domain)) if toxic end + def password_byte_length + return if skip_password_validation? || password.blank? + errors.add(:password, :bcrypt_length) if password.bytesize > 72 + end + def expire_all_api_keys - api_keys.unexpired.expire_all! + api_keys.expire_all! end def destroy_associations_for_discard @@ -358,4 +382,8 @@ def record_email_verified_event def record_password_update_event record_event!(Events::UserEvent::PASSWORD_CHANGED) end + + def unique_with_org_handle + errors.add(:handle, "has already been taken") if handle && Organization.where("lower(handle) = lower(?)", handle).any? + end end diff --git a/app/models/user/with_private_fields.rb b/app/models/user/with_private_fields.rb index 23a9a3a6952..164836e3dd9 100644 --- a/app/models/user/with_private_fields.rb +++ b/app/models/user/with_private_fields.rb @@ -10,12 +10,9 @@ def payload def mfa_warning if mfa_recommended_not_yet_enabled? - "[WARNING] For protection of your account and gems, we encourage you to set up multi-factor authentication " \ - "at https://rubygems.org/totp/new. Your account will be required to have MFA enabled in the future." + I18n.t("multifactor_auths.api.mfa_recommended_not_yet_enabled").chomp elsif mfa_recommended_weak_level_enabled? - "[WARNING] For protection of your account and gems, we encourage you to change your multi-factor authentication " \ - "level to 'UI and gem signin' or 'UI and API' at https://rubygems.org/settings/edit. " \ - "Your account will be required to have MFA enabled on one of these levels in the future." + I18n.t("multifactor_auths.api.mfa_recommended_weak_level_enabled").chomp end end end diff --git a/app/models/version.rb b/app/models/version.rb index 2bf906ccf02..f3aa3e75413 100644 --- a/app/models/version.rb +++ b/app/models/version.rb @@ -13,6 +13,7 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength belongs_to :pusher_api_key, class_name: "ApiKey", inverse_of: :pushed_versions, optional: true has_one :deletion, dependent: :delete, inverse_of: :version, required: false has_one :yanker, through: :deletion, source: :user, inverse_of: :yanked_versions, required: false + has_many :attestations, dependent: :destroy, inverse_of: :version before_validation :set_canonical_number, if: :number_changed? before_validation :full_nameify! @@ -47,6 +48,11 @@ class Version < ApplicationRecord # rubocop:disable Metrics/ClassLength allow_blank: true validates :sha256, :spec_sha256, format: { with: Patterns::BASE64_SHA256_PATTERN }, allow_nil: true + validates :number, :platform, :gem_platform, :full_name, :gem_full_name, :canonical_number, + name_format: { requires_letter: false }, + if: -> { validation_context == :create || number_changed? || platform_changed? }, + presence: true + validate :unique_canonical_number, on: :create validate :platform_and_number_are_unique, on: :create validate :gem_platform_and_number_are_unique, on: :create diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 21723d0d17d..4dca75e5368 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -21,12 +21,12 @@ class WebHook < ApplicationRecord scope :disabled, -> { where.not(disabled_at: nil) } def fire(protocol, host_with_port, version, delayed: true) - job = NotifyWebHookJob.new(webhook: self, protocol:, host_with_port:, version:) + job = NotifyWebHookJob.new(webhook: self, protocol:, host_with_port:, version:, poll_delivery: !delayed) if delayed job.enqueue else - job.perform_now + job.send(:_perform_job) end end diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb index 0d948384839..99ca6b3ce34 100644 --- a/app/models/webauthn_credential.rb +++ b/app/models/webauthn_credential.rb @@ -1,9 +1,9 @@ class WebauthnCredential < ApplicationRecord belongs_to :user - validates :external_id, uniqueness: true, presence: true - validates :public_key, presence: true - validates :nickname, presence: true, uniqueness: { scope: :user_id } + validates :external_id, uniqueness: true, presence: true, length: { maximum: 512 } + validates :public_key, presence: true, length: { maximum: 512 } + validates :nickname, presence: true, uniqueness: { scope: :user_id }, length: { maximum: 64 } validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0 } after_create :send_creation_email diff --git a/app/models/webauthn_verification.rb b/app/models/webauthn_verification.rb index 052d8b97925..95b19ae1485 100644 --- a/app/models/webauthn_verification.rb +++ b/app/models/webauthn_verification.rb @@ -2,7 +2,7 @@ class WebauthnVerification < ApplicationRecord belongs_to :user validates :user_id, uniqueness: true - validates :path_token, presence: true, uniqueness: true + validates :path_token, presence: true, uniqueness: true, length: { maximum: 128 } validates :path_token_expires_at, presence: true def expire_path_token diff --git a/app/policies/admin/github_user_policy.rb b/app/policies/admin/admin/github_user_policy.rb similarity index 85% rename from app/policies/admin/github_user_policy.rb rename to app/policies/admin/admin/github_user_policy.rb index 91eaabfb6c1..7f2dd7b7b4d 100644 --- a/app/policies/admin/github_user_policy.rb +++ b/app/policies/admin/admin/github_user_policy.rb @@ -1,4 +1,4 @@ -class Admin::GitHubUserPolicy < Admin::ApplicationPolicy +class Admin::Admin::GitHubUserPolicy < Admin::ApplicationPolicy class Scope < Admin::ApplicationPolicy::Scope # NOTE: Be explicit about which records you allow access to! def resolve diff --git a/app/policies/admin/attestation_policy.rb b/app/policies/admin/attestation_policy.rb new file mode 100644 index 00000000000..cbbb176ad10 --- /dev/null +++ b/app/policies/admin/attestation_policy.rb @@ -0,0 +1,15 @@ +class Admin::AttestationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + true + end + + def avo_show? + true + end +end diff --git a/app/policies/admin/events/organization_event_policy.rb b/app/policies/admin/events/organization_event_policy.rb new file mode 100644 index 00000000000..558bdd4694a --- /dev/null +++ b/app/policies/admin/events/organization_event_policy.rb @@ -0,0 +1,13 @@ +class Admin::Events::OrganizationEventPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :organization + has_association :ip_address + + def avo_index? = rubygems_org_admin? + def avo_show? = rubygems_org_admin? +end diff --git a/app/policies/admin/ip_address_policy.rb b/app/policies/admin/ip_address_policy.rb index e05baef361d..2f3fca42a56 100644 --- a/app/policies/admin/ip_address_policy.rb +++ b/app/policies/admin/ip_address_policy.rb @@ -7,6 +7,7 @@ def resolve has_association :user_events has_association :rubygem_events + has_association :organization_events def avo_index? = rubygems_org_admin? def avo_show? = rubygems_org_admin? diff --git a/app/policies/admin/membership_policy.rb b/app/policies/admin/membership_policy.rb new file mode 100644 index 00000000000..eb22ee86781 --- /dev/null +++ b/app/policies/admin/membership_policy.rb @@ -0,0 +1,11 @@ +class Admin::MembershipPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_show? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/oidc/api_key_role_policy.rb b/app/policies/admin/oidc/api_key_role_policy.rb index 51f0c3ae0c5..0c3914089b8 100644 --- a/app/policies/admin/oidc/api_key_role_policy.rb +++ b/app/policies/admin/oidc/api_key_role_policy.rb @@ -10,7 +10,5 @@ def resolve def avo_index? = rubygems_org_admin? def avo_show? = rubygems_org_admin? - def avo_create? = rubygems_org_admin? - def avo_update? = rubygems_org_admin? def act_on? = rubygems_org_admin? end diff --git a/app/policies/admin/organization_onboarding_invite_policy.rb b/app/policies/admin/organization_onboarding_invite_policy.rb new file mode 100644 index 00000000000..dcfa8c722b5 --- /dev/null +++ b/app/policies/admin/organization_onboarding_invite_policy.rb @@ -0,0 +1,19 @@ +class Admin::OrganizationOnboardingInvitePolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/organization_onboarding_policy.rb b/app/policies/admin/organization_onboarding_policy.rb new file mode 100644 index 00000000000..7a962588828 --- /dev/null +++ b/app/policies/admin/organization_onboarding_policy.rb @@ -0,0 +1,21 @@ +class Admin::OrganizationOnboardingPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :organization_onboarding_invites + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/organization_policy.rb b/app/policies/admin/organization_policy.rb new file mode 100644 index 00000000000..12291ad110a --- /dev/null +++ b/app/policies/admin/organization_policy.rb @@ -0,0 +1,22 @@ +class Admin::OrganizationPolicy < Admin::ApplicationPolicy + class Scope < Admin::ApplicationPolicy::Scope + def resolve + scope.all + end + end + + has_association :memberships + has_association :users + + def avo_index? + rubygems_org_admin? + end + + def avo_show? + rubygems_org_admin? + end + + def act_on? + rubygems_org_admin? + end +end diff --git a/app/policies/admin/user_policy.rb b/app/policies/admin/user_policy.rb index 91a1738210f..117f703d5d8 100644 --- a/app/policies/admin/user_policy.rb +++ b/app/policies/admin/user_policy.rb @@ -6,22 +6,24 @@ def resolve end end - has_association :ownerships - has_association :rubygems - has_association :subscriptions - has_association :subscribed_gems - has_association :deletions - has_association :web_hooks - has_association :unconfirmed_ownerships has_association :api_keys + has_association :audits + has_association :deletions + has_association :events + has_association :memberships + has_association :oidc_api_key_roles + has_association :organizations has_association :ownership_calls has_association :ownership_requests + has_association :ownerships has_association :pushed_versions - has_association :audits - has_association :oidc_api_key_roles + has_association :rubygems + has_association :subscribed_gems + has_association :subscriptions + has_association :unconfirmed_ownerships + has_association :web_hooks has_association :webauthn_credentials has_association :webauthn_verification - has_association :events def avo_index? rubygems_org_admin? diff --git a/app/policies/api/application_policy.rb b/app/policies/api/application_policy.rb index 29821e94bed..3d7d9b7f873 100644 --- a/app/policies/api/application_policy.rb +++ b/app/policies/api/application_policy.rb @@ -16,12 +16,13 @@ def resolve attr_reader :api_key, :scope end - attr_reader :api_key, :record + attr_reader :user, :record, :error, :api_key def initialize(api_key, record) - @api_key = api_key @user = api_key.user @record = record + @error = nil + @api_key = api_key end def index? = false @@ -34,7 +35,49 @@ def destroy? = false private - def policy!(user, record) = Pundit.policy!(user, record) - def user_policy! = policy!(api_key.user, record) - def api_key_scope?(...) = api_key.scope?(...) + delegate :t, to: I18n + + def deny(error) + @error = error + false + end + + def api_policy!(record) + Pundit.policy!(api_key, [:api, record]) + end + + def user_policy!(record) + Pundit.policy!(api_key.user, record) + end + + def api_authorized?(record, action) + policy = api_policy!(record) + policy.send(action) || deny(policy.error) + end + + def user_authorized?(record, action) + policy = user_policy!(record) + policy.send(action) || deny(policy.error) + end + + def api_key_scope?(scope, rubygem = nil) + api_key.scope?(scope, rubygem) || deny(t(:api_key_insufficient_scope)) + end + + def mfa_requirement_satisfied?(rubygem = nil) + if rubygem && !rubygem.mfa_requirement_satisfied_for?(user) + deny t("multifactor_auths.api.mfa_required") + elsif user&.mfa_required_not_yet_enabled? + deny t("multifactor_auths.api.mfa_required_not_yet_enabled").chomp + elsif user&.mfa_required_weak_level_enabled? + deny t("multifactor_auths.api.mfa_required_weak_level_enabled").chomp + else + true + end + end + + def user_api_key? + return true if user + deny t(:api_key_forbidden) + end end diff --git a/app/policies/api/nil_class_policy.rb b/app/policies/api/nil_class_policy.rb new file mode 100644 index 00000000000..820bb9d6e89 --- /dev/null +++ b/app/policies/api/nil_class_policy.rb @@ -0,0 +1,11 @@ +class Api::NilClassPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + def resolve + raise Pundit::NotDefinedError, "Cannot scope NilClass" + end + end + + def destroy? + false + end +end diff --git a/app/policies/api/oidc/rubygem_trusted_publisher_policy.rb b/app/policies/api/oidc/rubygem_trusted_publisher_policy.rb deleted file mode 100644 index 9723db57fee..00000000000 --- a/app/policies/api/oidc/rubygem_trusted_publisher_policy.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Api::OIDC::RubygemTrustedPublisherPolicy < Api::ApplicationPolicy - class Scope < Api::ApplicationPolicy::Scope - end - - delegate :rubygem, to: :record - - def show? - can_configure_trusted_publishers? && user_policy!.show? - end - - def create? - can_configure_trusted_publishers? && user_policy!.create? - end - - def destroy? - can_configure_trusted_publishers? && user_policy!.destroy? - end - - private - - def can_configure_trusted_publishers? - api_key_scope?(:configure_trusted_publishers, rubygem) - end -end diff --git a/app/policies/api/ownership_policy.rb b/app/policies/api/ownership_policy.rb new file mode 100644 index 00000000000..464b2376bf0 --- /dev/null +++ b/app/policies/api/ownership_policy.rb @@ -0,0 +1,11 @@ +class Api::OwnershipPolicy < Api::ApplicationPolicy + class Scope < Api::ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def update? + api_authorized?(rubygem, :update_owner?) && + user_authorized?(record, :update?) + end +end diff --git a/app/policies/api/rubygem_policy.rb b/app/policies/api/rubygem_policy.rb index 9c52e5273b6..690ede046c3 100644 --- a/app/policies/api/rubygem_policy.rb +++ b/app/policies/api/rubygem_policy.rb @@ -4,7 +4,46 @@ class Scope < Api::ApplicationPolicy::Scope alias rubygem record - def show_trusted_publishers? - api_key_scope?(:configure_trusted_publishers, rubygem) && user_policy!.show_trusted_publishers? + def index? + api_key_scope?(:index_rubygems) + end + + def create? + mfa_requirement_satisfied? && + api_key_scope?(:push_rubygem) + end + + def yank? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:yank_rubygem, rubygem) + end + + def add_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:add_owner, rubygem) && + user_authorized?(rubygem, :add_owner?) + end + + def update_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:update_owner, rubygem) && + user_authorized?(rubygem, :update_owner?) + end + + def remove_owner? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:remove_owner, rubygem) && + user_authorized?(rubygem, :remove_owner?) + end + + def configure_trusted_publishers? + user_api_key? && + mfa_requirement_satisfied?(rubygem) && + api_key_scope?(:configure_trusted_publishers, rubygem) && + user_authorized?(rubygem, :configure_trusted_publishers?) end end diff --git a/app/policies/api/web_hook_policy.rb b/app/policies/api/web_hook_policy.rb new file mode 100644 index 00000000000..51f8ea73473 --- /dev/null +++ b/app/policies/api/web_hook_policy.rb @@ -0,0 +1,28 @@ +class Api::WebHookPolicy < Api::ApplicationPolicy + class Scope < Api::ApplicationPolicy::Scope + end + + delegate :rubygem, to: :record + + def index? + can_access_webhooks? + end + + def create? + can_access_webhooks?(rubygem) + end + + def fire? + can_access_webhooks?(rubygem) + end + + def remove? + can_access_webhooks?(rubygem) + end + + private + + def can_access_webhooks?(rubygem = nil) + api_key_scope?(:access_webhooks, rubygem) + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 3f25a3cede5..45051f2f443 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -18,11 +18,12 @@ def resolve attr_reader :user, :scope end - attr_reader :user, :record + attr_reader :user, :record, :error def initialize(user, record) @user = user @record = record + @error = nil end def index? @@ -59,7 +60,44 @@ def search? private + delegate :t, to: I18n + + def deny(error = t(:forbidden)) + @error = error + false + end + + def allow + @error = nil + true + end + def current_user?(record_user) user && user == record_user end + + def rubygem_owned_by?(user) + rubygem.owned_by?(user) || + organization_member_with_role?(user, :maintainer) || + deny(t(:forbidden)) + end + + def rubygem_owned_by_with_role?(user, minimum_required_role:, minimum_required_org_role: :owner) + organization_member_with_role?(user, minimum_required_org_role) || + rubygem.owned_by_with_role?(user, minimum_required_role) || + deny(t(:forbidden)) + end + + def organization_member_with_role?(user, minimum_role) + return false unless respond_to?(:organization) && organization.present? + organization.memberships.where(user: user).with_minimum_role(minimum_role).exists? + end + + def policy!(user, record) = Pundit.policy!(user, record) + def user_policy!(record) = policy!(user, record) + + def user_authorized?(record, action) + policy = user_policy!(record) + policy.send(action) || deny(policy.error) + end end diff --git a/app/policies/membership_policy.rb b/app/policies/membership_policy.rb new file mode 100644 index 00000000000..2d77fcf4631 --- /dev/null +++ b/app/policies/membership_policy.rb @@ -0,0 +1,23 @@ +class MembershipPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + alias membership record + delegate :organization, to: :membership + + def create? + minimum_required_role = membership.owner? ? :owner : :admin + organization_member_with_role?(user, minimum_required_role) || deny(t(:forbidden)) + end + + def update? + minimum_required_role = :owner if membership.role_was.to_s == "owner" || membership.owner? + minimum_required_role ||= :admin + organization_member_with_role?(user, minimum_required_role) || deny(t(:forbidden)) + end + + def destroy? + minimum_required_role = membership.owner? ? :owner : :admin + organization_member_with_role?(user, minimum_required_role) || deny(t(:forbidden)) + end +end diff --git a/app/policies/oidc/rubygem_trusted_publisher_policy.rb b/app/policies/oidc/rubygem_trusted_publisher_policy.rb index 8d1400908af..cbd7896e834 100644 --- a/app/policies/oidc/rubygem_trusted_publisher_policy.rb +++ b/app/policies/oidc/rubygem_trusted_publisher_policy.rb @@ -5,14 +5,14 @@ class Scope < ApplicationPolicy::Scope delegate :rubygem, to: :record def show? - rubygem.owned_by?(user) + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) end def create? - rubygem.owned_by?(user) + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) end def destroy? - rubygem.owned_by?(user) + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) end end diff --git a/app/policies/organization_onboarding_policy.rb b/app/policies/organization_onboarding_policy.rb new file mode 100644 index 00000000000..7ef1e546c8e --- /dev/null +++ b/app/policies/organization_onboarding_policy.rb @@ -0,0 +1,4 @@ +class OrganizationOnboardingPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end +end diff --git a/app/policies/organization_policy.rb b/app/policies/organization_policy.rb new file mode 100644 index 00000000000..2dc35c3e1c6 --- /dev/null +++ b/app/policies/organization_policy.rb @@ -0,0 +1,38 @@ +class OrganizationPolicy < ApplicationPolicy + class Scope < ApplicationPolicy::Scope + end + + alias organization record + + def show? + true + end + + def update? + organization_member_with_role?(user, :owner) || deny(t(:forbidden)) + end + + def create? + true + end + + def add_gem? + organization_member_with_role?(user, :admin) || deny(t(:forbidden)) + end + + def remove_gem? + organization_member_with_role?(user, :owner) || deny(t(:forbidden)) + end + + def manage_memberships? + organization_member_with_role?(user, :admin) || deny(t(:forbidden)) + end + + def list_memberships? + organization_member_with_role?(user, :maintainer) || deny(t(:forbidden)) + end + + def destroy? + false # For now organizations cannot be deleted + end +end diff --git a/app/policies/ownership_call_policy.rb b/app/policies/ownership_call_policy.rb index b5f74a089b2..dafeff928cc 100644 --- a/app/policies/ownership_call_policy.rb +++ b/app/policies/ownership_call_policy.rb @@ -5,10 +5,10 @@ class Scope < ApplicationPolicy::Scope delegate :rubygem, to: :record def create? - rubygem.owned_by?(user) && current_user?(record.user) + user_authorized?(rubygem, :manage_adoption?) end def close? - rubygem.owned_by?(user) + user_authorized?(rubygem, :manage_adoption?) end end diff --git a/app/policies/ownership_policy.rb b/app/policies/ownership_policy.rb index a0ba9d4fac6..ad31e15c265 100644 --- a/app/policies/ownership_policy.rb +++ b/app/policies/ownership_policy.rb @@ -5,10 +5,16 @@ class Scope < ApplicationPolicy::Scope delegate :rubygem, to: :record def create? - rubygem.owned_by?(user) && current_user?(record.authorizer) + policy!(user, rubygem).add_owner? end + def update? + return deny(t("owners.update.update_current_user_role")) if current_user?(record.user) + policy!(user, rubygem).update_owner? + end + alias edit? update? + def destroy? - rubygem.owned_by?(user) + policy!(user, rubygem).remove_owner? end end diff --git a/app/policies/ownership_request_policy.rb b/app/policies/ownership_request_policy.rb index 88b16d29323..c1db2e38200 100644 --- a/app/policies/ownership_request_policy.rb +++ b/app/policies/ownership_request_policy.rb @@ -5,14 +5,14 @@ class Scope < ApplicationPolicy::Scope delegate :rubygem, to: :record def create? - current_user?(record.user) && Pundit.policy!(user, rubygem).request_ownership? + current_user?(record.user) && user_authorized?(rubygem, :request_ownership?) end def approve? - rubygem.owned_by?(user) + rubygem_owned_by?(user) end def close? - current_user?(record.user) || rubygem.owned_by?(user) + current_user?(record.user) || rubygem_owned_by?(user) end end diff --git a/app/policies/rubygem_policy.rb b/app/policies/rubygem_policy.rb index ef7dde21fd7..41d761d014c 100644 --- a/app/policies/rubygem_policy.rb +++ b/app/policies/rubygem_policy.rb @@ -5,6 +5,9 @@ class Scope < ApplicationPolicy::Scope ABANDONED_RELEASE_AGE = 1.year ABANDONED_DOWNLOADS_MAX = 10_000 + alias rubygem record + delegate :organization, to: :rubygem + def show? true end @@ -21,31 +24,50 @@ def destroy? false end - def show_adoption? - record.owned_by?(user) || request_ownership? + def configure_oidc? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) end - def show_events? - record.owned_by?(user) + def configure_trusted_publishers? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) + end + + def manage_adoption? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) end def request_ownership? - return false if record.owned_by?(user) - return true if record.ownership_calls.any? - return false if record.downloads >= ABANDONED_DOWNLOADS_MAX - return false unless record.latest_version&.created_at&.before?(ABANDONED_RELEASE_AGE.ago) - true + return allow if rubygem.ownership_calls.any? + return false if rubygem.downloads >= ABANDONED_DOWNLOADS_MAX + return false if rubygem.latest_version.nil? || rubygem.latest_version.created_at.after?(ABANDONED_RELEASE_AGE.ago) + allow end - def close_ownership_requests? - record.owned_by?(user) + def show_adoption? + manage_adoption? || request_ownership? + end + + def show_events? + rubygem_owned_by?(user) end - def show_trusted_publishers? - record.owned_by?(user) + def close_ownership_requests? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) end def show_unconfirmed_ownerships? - record.owned_by?(user) + rubygem_owned_by_with_role?(user, minimum_required_role: :owner, minimum_required_org_role: :admin) + end + + def add_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def update_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) + end + + def remove_owner? + rubygem_owned_by_with_role?(user, minimum_required_role: :owner) end end diff --git a/app/views/adoptions/index.html.erb b/app/views/adoptions/index.html.erb index 48afcc14296..7d6ae93012f 100644 --- a/app/views/adoptions/index.html.erb +++ b/app/views/adoptions/index.html.erb @@ -3,7 +3,7 @@ <% content_for :title do %>

    <%= t('.title') %> - <% if @rubygem.owned_by?(current_user) %> + <% if policy(@rubygem).manage_adoption? %> <%= t(".subtitle_owner_html", gem: @rubygem.name) %> <% else %> <%= t(".subtitle_user_html", gem: @rubygem.name) %> @@ -24,12 +24,12 @@ <%= t("ownership_calls.created_by") %>: <%= link_to @ownership_call.user_display_handle, profile_path(@ownership_call.user), class: "t-text t-link" %>

    - <% if @ownership_call.rubygem.owned_by?(current_user) %> + <% if policy(@ownership_call).close? %> <%= button_to t("ownership_calls.close"), close_rubygem_ownership_calls_path(@ownership_call.rubygem.slug), method: :patch, class: "form__submit form__submit--medium" %> <% end %> -<% elsif @rubygem.owned_by?(current_user) %> +<% elsif policy(@rubygem).manage_adoption? %> <%= render partial: "ownership_calls/form", locals: { gem: @rubygem.name } %> <% else %>
    diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb index c4ace3d48fe..c1816ee1a9c 100644 --- a/app/views/api_keys/index.html.erb +++ b/app/views/api_keys/index.html.erb @@ -9,6 +9,12 @@
    <% end %> +
    +

    + <%= page_entries_info @api_keys, entry_name: 'API keys' %> +

    +
    +
    @@ -85,6 +91,7 @@ <% end %> + @@ -114,6 +121,8 @@
    + <%= paginate @api_keys %> +

    <%= button_to t(".new_key"), new_profile_api_key_path, method: "get", class: "form__submit" %>

    <% if current_user.oidc_api_key_roles.any? %>

    <%= link_to t("oidc.api_key_roles.index.api_key_roles"), profile_oidc_api_key_roles_path, class: "t-link t-underline" %> →

    diff --git a/app/views/avo/login.html.erb b/app/views/avo/login.html.erb index 3c119593efb..d3f04dca01f 100644 --- a/app/views/avo/login.html.erb +++ b/app/views/avo/login.html.erb @@ -13,7 +13,7 @@

    To reach the admin panel, please log in via GitHub.

    - <%= form_tag ActionDispatch::Http::URL.path_for(path: '/oauth/github', params: { origin: request.fullpath }) do %> + <%= form_tag ActionDispatch::Http::URL.path_for(path: '/oauth/github', params: { origin: request.fullpath }, data: { turbo: false }) do %> <% end %> - <% if Gemcutter::ENABLE_DEVELOPMENT_ADMIN_LOG_IN %> + <% if Gemcutter::ENABLE_DEVELOPMENT_LOG_IN %>

    Since this is development mode, you can choose any admin from the list below to log in as that admin.

    <% if Admin::GitHubUser.any? %>
      diff --git a/app/views/components/alert_component.rb b/app/views/components/alert_component.rb new file mode 100644 index 00000000000..6c5952c423b --- /dev/null +++ b/app/views/components/alert_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class AlertComponent < ApplicationComponent + attr_reader :style, :closeable + + def initialize(style: :notice, closeable: false) + super() + @style = style.to_sym + @style = :notice if @style == :notice_html + @style = :neutral unless STYLES.key?(@style) + @closeable = closeable + end + + def view_template(&) + color, icon_color, icon = STYLES.fetch(style) + data = { controller: "reveal", reveal_target: "item" } if closeable + p(data:, class: "flex flex-row items-center p-4 mb-10 rounded border text-b2 #{color} justify-between") do + span(class: "flex flex-row items-center") do + unsafe_raw helpers.icon_tag(icon, size: 8, class: "#{icon_color} mr-3 h-8 w-8") + span(class: "align-middle", &) + end + if closeable + button(data: { action: "click->reveal#hide" }, title: t("hide"), class: "h-8 w-8 ml-6 items-center justify-center outline-none") do + unsafe_raw helpers.icon_tag("close", class: "w-6 h-6", aria: { label: t("hide") }) + end + end + end + end + + COLORS = { + orange: "border-orange-500 bg-orange-200 text-neutral-800 " \ + "dark:bg-orange-900 dark:text-white", + yellow: "border-yellow-500 bg-yellow-200 text-neutral-800 " \ + "dark:bg-yellow-900 dark:text-white", + blue: "border-blue-500 bg-blue-200 text-neutral-800 " \ + "dark:bg-blue-900 dark:text-white", + green: "border-green-500 bg-green-200 text-neutral-800 " \ + "dark:bg-green-900 dark:text-white", + red: "border-red-500 bg-red-200 text-neutral-800 " \ + "dark:bg-red-900 dark:text-white", + neutral: "border-neutral-500 bg-neutral-200 text-neutral-800 " \ + "dark:bg-neutral-900 dark:text-white" + }.freeze + + ICON_COLORS = { + orange: "fill-orange-500", + yellow: "fill-yellow-600", + blue: "fill-blue-500", + green: "fill-green-500", + red: "fill-red-400", + neutral: "fill-neutral-800 dark:fill-neutral-500" + }.freeze + + STYLES = { + error: [COLORS[:red], ICON_COLORS[:red], "error"], + alert: [COLORS[:yellow], ICON_COLORS[:yellow], "warning"], + notice: [COLORS[:blue], ICON_COLORS[:blue], "arrow-circle-right"], + success: [COLORS[:green], ICON_COLORS[:green], "check-circle"], + primary: [COLORS[:orange], ICON_COLORS[:orange], "arrow-circle-right"], + neutral: [COLORS[:neutral], ICON_COLORS[:neutral], "arrow-circle-right"] + }.freeze +end diff --git a/app/views/components/application_component.rb b/app/views/components/application_component.rb index 75773f70368..8e8d1aa0d40 100644 --- a/app/views/components/application_component.rb +++ b/app/views/components/application_component.rb @@ -2,6 +2,7 @@ class ApplicationComponent < Phlex::HTML include Phlex::Rails::Helpers::Routes + extend PropInitializer::Properties class TranslationHelper include ActionView::Helpers::TranslationHelper diff --git a/app/views/components/button_component.rb b/app/views/components/button_component.rb new file mode 100644 index 00000000000..ff8e572a621 --- /dev/null +++ b/app/views/components/button_component.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class ButtonComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::ButtonTo + + attr_reader :args, :type, :options, :color, :size, :style + + def initialize(*args, type: :button, size: :large, color: :primary, style: :fill, **options) # rubocop:disable Metrics/ParameterLists + super() + @args = args + @type = type + @size = size + @color = color + @style = style + @options = options + @options[:name] ||= nil + end + + def view_template(&block) + css = "text-nowrap no-underline " \ + "rounded inline-flex border-box " \ + "justify-content-center items-center hover:shadow-md " \ + "#{DISABLED} disabled:cursor-default disabled:hover:shadow-none " \ + "transition duration-200 ease-in-out focus:outline-none " \ + "#{button_color(color, style)} #{button_size(size)} #{options.delete(:class)}" + + if type == :link + link_to(*args, class: css, **options, &block) + elsif (args.size == 1 && block_given?) || args.size == 2 + button_to(*args, class: css, method: :get, **options, &block) + else + block ||= proc { args.first } + button(class: css, type:, **options, &block) + end + end + + private + + def button_color(color, style) + color = color.to_sym + color = :orange if color == :primary + color = :hammy if color == :secondary + STYLES[style][color] + end + + def button_size(size) + case size + when :small # 36px height + "px-4 py-3 h-9 min-h-9 text-b3" + else # :large, 44px height + "px-4 py-3 h-12 min-h-12 text-b2" + end + end + + DISABLED = "disabled:bg-neutral-200 disabled:border-neutral-200 disabled:text-neutral-600 " \ + "dark:disabled:bg-neutral-800 dark:disabled:border-neutral-800 dark:disabled:text-neutral-600" + + FILL_BUTTON_COLOR = { + red: "text-white bg-red-500 hover:bg-red-600 active:bg-red-600 " \ + "dark:bg-red-500 dark:hover:bg-red-600 dark:active:bg-red-600", + orange: "text-white bg-orange-500 hover:bg-orange-600 active:bg-orange-600 " \ + "dark:bg-orange-500 dark:hover:bg-orange-700 dark:active:bg-orange-700", + hammy: "text-orange-900 bg-orange-200 hover:bg-orange-300 active:bg-orange-300 " \ + "dark:text-white dark:bg-orange-800 dark:hover:bg-orange-900 dark:active:bg-orange-900", + yellow: "text-neutral-800 bg-yellow-500 hover:bg-yellow-600 active:bg-yellow-600 " \ + "dark:text-white dark:bg-yellow-500 dark:hover:bg-yellow-600 dark:active:bg-yellow-600", + green: "text-white bg-green-500 hover:bg-green-600 active:bg-green-600 " \ + "dark:bg-green-500 dark:hover:bg-green-700 dark:active:bg-green-700", + blue: "text-white bg-blue-500 hover:bg-blue-600 active:bg-blue-600 " \ + "dark:bg-blue-500 dark:hover:bg-blue-700 dark:active:bg-blue-700", + neutral: "text-white bg-neutral-700 hover:bg-neutral-600 active:bg-neutral-600 " \ + "dark:bg-neutral-700 dark:hover:bg-neutral-800 dark:active:bg-neutral-800" + }.freeze + + PLAIN_BUTTON_COLOR = { + red: "text-red-500 hover:bg-red-500/5 active:bg-red-500/10 " \ + "dark:hover:bg-red-500/15 dark:active:bg-red-500/25 ", + orange: "text-orange-500 hover:bg-orange-500/5 active:bg-orange-500/10 " \ + "dark:hover:bg-orange-500/15 dark:active:bg-orange-500/25 ", + hammy: "text-orange-800 hover:bg-orange-200/15 active:bg-orange-200/25 " \ + "dark:text-orange-200 dark:hover:bg-orange-200/15 dark:active:bg-orange-200/25 ", + yellow: "text-yellow-500 hover:bg-yellow-500/5 active:bg-yellow-500/10 " \ + "dark:hover:bg-yellow-500/15 dark:active:bg-yellow-500/25 ", + green: "text-green-500 hover:bg-green-500/5 active:bg-green-500/10 " \ + "dark:hover:bg-green-500/15 dark:active:bg-green-500/25 ", + blue: "text-blue-500 hover:bg-blue-500/5 active:bg-blue-500/10 " \ + "dark:hover:bg-blue-500/15 dark:active:bg-blue-500/25 ", + neutral: "text-neutral-700 hover:bg-neutral-700/5 active:bg-neutral-700/10 " \ + "dark:text-white dark:hover:bg-white/15 dark:active:bg-white/25" + }.freeze + + OUTLINE_BUTTON_COLOR = { + red: "#{PLAIN_BUTTON_COLOR[:red]} border-2 border-red-500", + orange: "#{PLAIN_BUTTON_COLOR[:orange]} border-2 border-orange-500", + hammy: "#{PLAIN_BUTTON_COLOR[:hammy]} border-2 border-orange-800 dark:border-orange-200", + yellow: "#{PLAIN_BUTTON_COLOR[:yellow]} border-2 border-yellow-500", + green: "#{PLAIN_BUTTON_COLOR[:green]} border-2 border-green-500", + blue: "#{PLAIN_BUTTON_COLOR[:blue]} border-2 border-blue-500", + neutral: "#{PLAIN_BUTTON_COLOR[:neutral]} border-2 border-neutral-700 dark:border-white" + }.freeze + + STYLES = { + fill: FILL_BUTTON_COLOR, + outline: OUTLINE_BUTTON_COLOR, + plain: PLAIN_BUTTON_COLOR + }.freeze +end diff --git a/app/views/components/card/timeline_component.rb b/app/views/components/card/timeline_component.rb new file mode 100644 index 00000000000..7f7ec86885e --- /dev/null +++ b/app/views/components/card/timeline_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Card::TimelineComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::TimeAgoInWords + + def view_template(&) + div(class: "flex grow ml-2 md:-ml-2 border-l-2 border-neutral-300") do + div(class: "flex flex-col grow -mt-2", &) + end + end + + def timeline_item(datetime, user_link = nil, &) + # this block is shifted left to align the dots with the line + div(class: "flex items-start -ml-2 mb-4") do + # Dot + div(class: "relative z-10 top-0.5 left-[1px] w-3 h-3 bg-orange-600 rounded-full flex-shrink-0 mt-1") + + # Content + div(class: "flex-1 flex-col ml-5 md:ml-7 pb-4 border-b border-neutral-300 dark:border-neutral-700") do + div(class: "flex items-center justify-between") do + span(class: "text-b3 text-neutral-600") { t("time_ago", duration: time_ago_in_words(datetime)) } + span(class: "text-b3 text-neutral-800") { user_link } if user_link + end + + div(class: "flex flex-wrap w-full items-center justify-between", &) + end + end + end +end diff --git a/app/views/components/card_component.rb b/app/views/components/card_component.rb new file mode 100644 index 00000000000..fec79c3f747 --- /dev/null +++ b/app/views/components/card_component.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class CardComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + + def view_template(&) + color = "bg-white dark:bg-black border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white " + box = "w-full px-4 py-6 md:p-10 mb-10 rounded-md shadow overflow-hidden" + article(**classes(color, box), &) + end + + def head(title = nil, icon: nil, count: nil, url: nil, **options, &block) + block ||= proc do + title(title, icon:, count:) + a(href: url, class: "text-sm text-orange-500 hover:underline") { t("view_all") } if url + end + options[:class] = "#{options[:class]} flex justify-between items-center -mx-10 px-10 pb-8" + div(**options, &block) + end + + def title(title, icon: nil, count: nil) + h3(class: "flex items-center text-lg space-x-2") do + render_icon(icon, class: "fill-orange") if icon + span(class: "font-semibold") { title } + # when count is 0, don't show the count because it's more confusing than helpful + span(class: "font-light text-neutral-600") { count } unless count.to_i.zero? + end + end + + def with_list(items, &) + list do + items.each do |item| + list_item do + yield(item) + end + end + end + end + + def list(**options, &) + options[:class] = "#{options[:class]} -mx-4" + ul(**options, &) + end + + def divided_list(**options, &) + options[:class] = "#{options[:class]} -mx-4 divide-y divide-neutral-200 dark:divide-neutral-800" + ul(**options, &) + end + + def list_item(**options, &) + options[:class] = "#{options[:class]} #{LIST_ITEM_CLASSES}" + li(**options, &) + end + + def list_item_to(url = nil, **options, &) + options[:class] = "#{options[:class]} #{LIST_ITEM_CLASSES}" + li do + link_to(url, **options) do + span(class: "flex-1", &) + render_icon("arrow-forward-ios", class: "w-8 h-8 ml-2 -mr-2 text-neutral-800 dark:text-white fill-current") + end + end + end + + # removes padding inside the "content" area of the card so scroll bar and overflaw appear correctly + # adds a border to the top of the scrollable area to explain the content being hidden on scroll + def scrollable(**options, &) + options[:class] = "#{options[:class]} lg:max-h-96 lg:overflow-y-auto" + options[:class] << " -mx-4 -mb-6 md:-mx-10 md:-mb-10" + options[:class] << " border-t border-neutral-200 dark:border-neutral-800" + div(**options) do + div(class: "px-4 pt-6 md:px-10 md:pt-10", &) + end + end + + private + + LIST_ITEM_CLASSES = "flex w-full px-4 py-3 " \ + "items-center md:rounded " \ + "hover:bg-neutral-100 dark:hover:bg-neutral-800" + + def render_icon(name, size: 8, **) + unsafe_raw helpers.icon_tag(name, size: size, **) + end +end diff --git a/app/views/components/events/table_component.rb b/app/views/components/events/table_component.rb index e6955d33857..ea1fef7b768 100644 --- a/app/views/components/events/table_component.rb +++ b/app/views/components/events/table_component.rb @@ -12,9 +12,7 @@ class Events::TableComponent < ApplicationComponent register_value_helper :page_entries_info register_value_helper :paginate - extend Dry::Initializer - - option :security_events + prop :security_events, reader: :public def view_template header(class: "gems__header push--s") do diff --git a/app/views/components/events/table_details_component.rb b/app/views/components/events/table_details_component.rb index c97cf39ae79..49e07e748ce 100644 --- a/app/views/components/events/table_details_component.rb +++ b/app/views/components/events/table_details_component.rb @@ -1,7 +1,7 @@ class Events::TableDetailsComponent < ApplicationComponent - extend Dry::Initializer + extend PropInitializer::Properties - option :event + prop :event, reader: :public delegate :additional, :rubygem, to: :event def view_template diff --git a/app/views/components/oidc/api_key_role/table_component.rb b/app/views/components/oidc/api_key_role/table_component.rb index dce49298fb9..e00a87474eb 100644 --- a/app/views/components/oidc/api_key_role/table_component.rb +++ b/app/views/components/oidc/api_key_role/table_component.rb @@ -2,9 +2,8 @@ class OIDC::ApiKeyRole::TableComponent < ApplicationComponent include Phlex::Rails::Helpers::LinkTo - extend Dry::Initializer - option :api_key_roles + prop :api_key_roles, reader: :public def view_template table(class: "t-body") do diff --git a/app/views/components/oidc/id_token/key_value_pairs_component.rb b/app/views/components/oidc/id_token/key_value_pairs_component.rb index ed71fe2d19f..8052eb446ad 100644 --- a/app/views/components/oidc/id_token/key_value_pairs_component.rb +++ b/app/views/components/oidc/id_token/key_value_pairs_component.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class OIDC::IdToken::KeyValuePairsComponent < ApplicationComponent - extend Dry::Initializer - option :pairs + prop :pairs, reader: :public def view_template dl(class: "t-body provider_attributes full-width overflow-wrap") do diff --git a/app/views/components/oidc/id_token/table_component.rb b/app/views/components/oidc/id_token/table_component.rb index da8b45b90a0..8c8541afdf8 100644 --- a/app/views/components/oidc/id_token/table_component.rb +++ b/app/views/components/oidc/id_token/table_component.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class OIDC::IdToken::TableComponent < ApplicationComponent - extend Dry::Initializer - option :id_tokens + prop :id_tokens, reader: :public include Phlex::Rails::Helpers::TimeTag include Phlex::Rails::Helpers::LinkToUnlessCurrent diff --git a/app/views/components/oidc/trusted_publisher/github_action/form_component.rb b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb index 677f57c0a45..de52e3282be 100644 --- a/app/views/components/oidc/trusted_publisher/github_action/form_component.rb +++ b/app/views/components/oidc/trusted_publisher/github_action/form_component.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class OIDC::TrustedPublisher::GitHubAction::FormComponent < ApplicationComponent - extend Dry::Initializer - - option :github_action_form + prop :github_action_form, reader: :public def view_template github_action_form.fields_for :trusted_publisher do |trusted_publisher_form| diff --git a/app/views/components/oidc/trusted_publisher/github_action/table_component.rb b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb index 9174f2d1602..ecb8e83242b 100644 --- a/app/views/components/oidc/trusted_publisher/github_action/table_component.rb +++ b/app/views/components/oidc/trusted_publisher/github_action/table_component.rb @@ -1,7 +1,5 @@ class OIDC::TrustedPublisher::GitHubAction::TableComponent < ApplicationComponent - extend Dry::Initializer - - option :github_action + prop :github_action, reader: :public def view_template dl(class: "tw-flex tw-flex-col sm:tw-grid sm:tw-grid-cols-2 tw-items-baseline tw-gap-4 full-width overflow-wrap") do diff --git a/app/views/components/onboarding/steps_component.rb b/app/views/components/onboarding/steps_component.rb new file mode 100644 index 00000000000..5744ca2dd4d --- /dev/null +++ b/app/views/components/onboarding/steps_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Onboarding::StepsComponent < ApplicationComponent + include Phlex::DeferredRender + + include Phlex::Rails::Helpers::LinkTo + + def initialize(current_step) + @current_step = current_step + @steps = [] + super() + end + + def view_template(&) + nav(class: "mb-10 flex items-start text-start text-neutral-800 dark:text-white") do + @steps.each_with_index do |step, idx| + step_item(idx + 1, *step) + connector(idx + 1) unless idx == @steps.size - 1 + end + end + end + + def step(name, link) + @steps << [name, link] + end + + private + + STEP = "w-8 h-8 flex items-center justify-center rounded font-bold" + ACTIVE_STEP = "#{STEP} bg-orange hover:bg-orange-600 text-white".freeze + PENDING_STEP = "#{STEP} bg-neutral-300 dark:bg-neutral-700 text-neutral-700 dark:text-white".freeze + + def step_item(step, name, link) + a(href: link, class: "relative z-10 w-20 flex flex-col items-center space-y-2 text-center text-b3") do + span(class: @current_step >= step ? ACTIVE_STEP : PENDING_STEP, aria_current: "step") { step } + p(class: "") { name } + end + end + + def connector(step) + color = @current_step > step ? "border-orange-500" : "border-neutral-300 dark:border-neutral-700" + span(class: "flex-grow mt-4 -mx-6 border-t-2 #{color}") + end +end diff --git a/app/views/components/subject/nav_component.rb b/app/views/components/subject/nav_component.rb new file mode 100644 index 00000000000..2730dc08372 --- /dev/null +++ b/app/views/components/subject/nav_component.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Subject::NavComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + + attr_reader :current + + def initialize(current: :dashboard) + @current = current + super() + end + + def view_template(&) + div(class: "relative -mx-8 lg:mx-0") do + nav(data: { controller: "scroll" }, class: NAV, &) + div class: "#{GRADIENT} w-12 right-0 bg-gradient-to-l" # Gradient fade right + div class: "#{GRADIENT} w-8 left-0 bg-gradient-to-r" # Gradient fade left + end + end + + NAV = "relative flex overflow-x-auto no-scrollbar whitespace-nowrap py-4 space-x-2 pl-8 pr-12 " \ + "lg:flex-col lg:px-0 lg:space-x-0 lg:space-y-2" + GRADIENT = "lg:hidden absolute top-0 h-full pointer-events-none from-white dark:from-black" + LINK = "flex items-center space-x-2 h-12 lg:h-14 px-3 py-1 lg:px-6 lg:py-2 rounded" + ACTIVE_LINK = "#{LINK} bg-orange-100 dark:bg-orange-900 text-neutral-900 dark:text-white".freeze + INACTIVE_LINK = "#{LINK} bg-neutral-050 text-neutral-600 hover:bg-neutral-200 " \ + "dark:bg-neutral-950 dark:text-neutral-400 dark:hover:bg-neutral-800".freeze + + def link(text, url, icon:, name: nil, **options) + is_current = name == current + data = { scroll_target: "scrollLeft" } if is_current + options[:class] = "#{options[:class]} #{is_current ? ACTIVE_LINK : INACTIVE_LINK}" + link_to(url, data:, **options) do + unsafe_raw helpers.icon_tag(icon, size: 7, class: is_current && "text-orange-500") + span { text } + end + end +end diff --git a/app/views/components/version/provenance_component.rb b/app/views/components/version/provenance_component.rb new file mode 100644 index 00000000000..fb454d5acc1 --- /dev/null +++ b/app/views/components/version/provenance_component.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Version::ProvenanceComponent < ApplicationComponent + include Phlex::Rails::Helpers::LinkTo + + prop :attestation + + def view_template + display_data = @attestation.display_data + + div(class: "gem__attestation") do + div(class: "gem__attestation__built_on") do + div(class: "gem__attestation__grid gem__attestation__grid__left") do + p + p { plain "Built and signed on" } + p { "✅" } + p { display_data[:ci_platform] } + p + p { link_to "Build summary", display_data[:build_summary_url], target: "_blank", rel: "noopener" } + end + end + div(class: "gem__attestation__grid gem__attestation__grid__right") do + p { plain "Source Commit" } + p { link_to display_data[:source_commit_string], display_data[:source_commit_url] } + p { plain "Build File" } + p { link_to display_data[:build_file_string], display_data[:build_file_url] } + p { plain "Public Ledger" } + p { link_to "Transparency log entry", "https://search.sigstore.dev/?logIndex=#{display_data[:log_index]}" } + end + end + end +end diff --git a/app/views/dashboards/_promo.html.erb b/app/views/dashboards/_promo.html.erb new file mode 100644 index 00000000000..bc7e62dbff4 --- /dev/null +++ b/app/views/dashboards/_promo.html.erb @@ -0,0 +1,43 @@ +<% return unless current_user.memberships.any? %> +
      +

      + <%= icon_tag "organizations", size: 8, class: "mr-2 inline-block" %> + Introducing Organizations! +

      +
      +
      +
      +
      +

      Organization

      +
      +

      Owner

      +
      +

      Admin

      +
      +

      Maintainer

      +
      +

      Outside contributor

      +
      +
      +
      +
      +
      +
      +
      + +
      +

      + RubyGems is making ownership and permissions easier with organizations. Name your org then add your gems. It’s that easy! +

      + +
      + <%= render ButtonComponent.new(organization_onboarding_name_path, type: :link, style: :outline, color: :primary) do %> + + Begin + <%= icon_tag "arrow-forward", size: 6, class: "ml-2 inline-block" %> + + <% end %> +
      +
      +
      +
      diff --git a/app/views/dashboards/_subject.html.erb b/app/views/dashboards/_subject.html.erb new file mode 100644 index 00000000000..64981449be9 --- /dev/null +++ b/app/views/dashboards/_subject.html.erb @@ -0,0 +1,42 @@ +<% + user ||= @user || current_user + current ||= :dashboard +%> + +
      + <%= avatar 328, "user_gravatar", theme: :dark, class: "h-24 w-24 lg:h-40 lg:w-40 rounded-lg object-cover mr-4" %> + +
      +

      <%= user.display_handle %>

      + <% if user.full_name.present? %> +

      <%= user.full_name %>

      + <% end %> +
      +
      + +<% if user.public_email? || user == current_user %> +
      + <%= icon_tag("mail", color: :primary, class: "h-6 w-6 text-orange mr-3") %> +

      <%= + mail_to(user.email, encode: "hex") + %>

      +
      +<% end %> + +<% if user.twitter_username.present? %> +
      + <%= icon_tag("x-twitter", color: :primary, class: "w-6 text-orange mr-3") %> +

      <%= + link_to( + twitter_username(user), + twitter_url(user) + ) + %>

      +
      +<% end %> + +<%= render Subject::NavComponent.new(current:) do |nav| %> + <%= nav.link t("layouts.application.header.dashboard"), dashboard_path, name: :dashboard, icon: "space-dashboard" %> + <%= nav.link t("dashboards.show.my_subscriptions"), subscriptions_path, name: :subscriptions, icon: "notifications" %> + <%= nav.link t("layouts.application.header.settings"), edit_settings_path, name: :settings, icon: "settings" %> +<% end %> diff --git a/app/views/dashboards/show.html.erb b/app/views/dashboards/show.html.erb index 49b2b76c30a..5cfb8105f25 100644 --- a/app/views/dashboards/show.html.erb +++ b/app/views/dashboards/show.html.erb @@ -1,67 +1,113 @@ <% @title = t('.title') %> -
      -
      -

      - <%= t '.latest' %>: -

      - <%= link_to dashboard_path(:api_key => current_user.api_key, :format => :atom), :id => 'feed', :title => t('.latest_title'), :class => 'gem__link t-list__item', 'data-icon' => '#' do %> - RSS - <% end %> - -
      - <% if @latest_updates.empty? %> -
      - <%= t('.no_subscriptions_html', :gem_link => link_to(t('.gem_link_text'), rubygem_path("rake"))) %> -
      - <% else %> -
        - <% @latest_updates.each do |version| %> -
      1. - -
        - <%= version.to_title %> - <%= t 'time_ago', :duration => time_ago_in_words(version.authored_at) %> -
        -

        <%= short_info(version) %>

        -
        -
      2. - <% end %> -
      +<% content_for :subject do %> + <% render "dashboards/subject", user: current_user %> +<% end %> + + +

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

      + +<%= render "dashboards/promo" %> + +<%= render CardComponent.new do |c| %> + <%= c.head(divide: true) do %> + <%= c.title t(".latest"), icon: :history %> +
      + <%= link_to dashboard_path(api_key: current_user.api_key, format: :atom), id: 'feed', title: t('.latest_title'), class: 'items-center' do %> + <%= icon_tag("rss-feed", size: 6, class: "w-6 h-6 fill-orange") %> <% end %>
      -
      + <% end %> -
      -
      -

      <%= t '.mine' %>

      - <% if @my_gems.empty? %> -
      -

      - <%= t('.no_owned_html', :creating_link => link_to(t('.creating_link_text'), "https://guides.rubygems.org/make-your-own-gem/"), - :migrating_link => link_to(t('.migrating_link_text'), page_path("migrate"))) %> -

      -
      - <% else %> -
        - <% @my_gems.each do |rubygem| %> -
      • - <%= link_to rubygem.name, rubygem_path(rubygem.slug), :title => short_info(rubygem.most_recent_version), :class => 't-link' %> -
      • + <% if @latest_updates.empty? %> + <%= prose do %> + <%= t('.no_subscriptions_html', :gem_link => link_to(t('.gem_link_text'), rubygem_path("rake"))) %> + <% end %> + <% else %> + <%= c.scrollable do %> + <%= render Card::TimelineComponent.new do |t| %> + <% @latest_updates.each do |version| %> + <% + pusher_link = if version.pusher.present? + link_to_user(version.pusher) + elsif version.pusher_api_key&.owner.present? + link_to_pusher(version.pusher_api_key.owner) + end + %> + <%= t.timeline_item(version.authored_at, pusher_link) do %> +
        <%= link_to version.rubygem.name, rubygem_path(version.rubygem.slug) %>
        + <%= version_number(version) %> <% end %> -
      + <% end %> <% end %> + <% end %> + <% end %> +<% end %> - <% if @subscribed_gems.present? %> -

      <%= t '.my_subscriptions' %>

      -
        - <% current_user.subscribed_gems.each do |gem| %> -
      • - <%= link_to gem, rubygem_path(gem.slug), :title => short_info(gem.most_recent_version), :class => 't-link' %> -
      • - <% end %> -
      +<%= render CardComponent.new do |c| %> + <%= c.head do %> + <%= c.title t(".mine"), icon: "gems", count: @my_gems_count %> + <% end %> + <% if @my_gems.empty? %> + <%= prose do %> + <%= t('.no_owned_html', :creating_link => link_to(t('.creating_link_text'), "https://guides.rubygems.org/make-your-own-gem/")) %> + <% end %> + <% else %> + <%= c.divided_list do %> + <% @my_gems.each do |rubygem| %> + <%= c.list_item_to( + rubygem_path(rubygem.slug), + title: short_info(rubygem.most_recent_version), + ) do %> +
      +
      +

      <%= rubygem.name %>

      + <%= version_number(rubygem.most_recent_version) %> +
      +
      + <%= download_count_component(rubygem, class: "flex") %> +
      <%= version_date_component(rubygem.most_recent_version) %>
      +
      +
      + <% end %> <% end %> -
      -
      -
      + <% end %> + <% end %> +<% end %> + +<% if @subscribed_gems.present? %> + <%= render CardComponent.new do |c| %> + <%= c.head do %> + <%= c.title t(".my_subscriptions"), icon: "notifications", count: @subscribed_gems_count %> + <%= link_to t("view_all"), subscriptions_path, class: "text-sm text-orange-500" %> + <% end %> + <%= c.list do %> + <% @subscribed_gems.each do |gem| %> + <%= c.list_item_to(rubygem_path(gem.slug), title: short_info(gem.most_recent_version)) do %> +

      <%= gem.name %>

      +

      <%= short_info(gem.most_recent_version) %>

      + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> + +<% if current_user.memberships.any? %> + + <%= render CardComponent.new do |c| %> + <%= c.head do %> + <%= c.title t(".organizations"), icon: "organizations", count: current_user.memberships.count %> + <%= link_to t("view_all"), "#", class: "text-sm text-orange-500" %> + <% end %> + <%= c.divided_list do %> + <% current_user.memberships.preload(:organization).each do |membership| %> + <%= c.list_item_to("#") do %> +
      +

      <%= membership.organization.name %>

      +

      <%= membership.role %>

      +
      + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/layouts/_breadcrumbs.html.erb b/app/views/layouts/_breadcrumbs.html.erb new file mode 100644 index 00000000000..d59f3a3e87f --- /dev/null +++ b/app/views/layouts/_breadcrumbs.html.erb @@ -0,0 +1,20 @@ + diff --git a/app/views/layouts/_search.html.erb b/app/views/layouts/_search.html.erb index d7f395b2818..b5bf61009f5 100644 --- a/app/views/layouts/_search.html.erb +++ b/app/views/layouts/_search.html.erb @@ -1,7 +1,10 @@ <% home = current_page?(root_path) || current_page?(advanced_search_path) %>
      " role="search"> <%= form_tag search_path, method: :get, data: { controller: "autocomplete", autocomplete_selected_class: "selected", } do %> - <%= search_field(home: home) %> + <%= rubygem_search_field( + class: (home ? "home__search" : "header__search"), + data: (home ? nil : { nav_target: "search" }), + ) %>
        diff --git a/app/views/layouts/_session.html.erb b/app/views/layouts/_session.html.erb new file mode 100644 index 00000000000..677752bc08b --- /dev/null +++ b/app/views/layouts/_session.html.erb @@ -0,0 +1,53 @@ +
        + <% if signed_in? %> + <%# This class is used in the tests :( I need it to be the same class until the old design is gone. %> + <%= link_to(dashboard_path, data: { action: "dialog#open", dialog_target: "button" }, class: "header__popup-link") do %> + <%= avatar 64, "user_gravatar", theme: :dark, class: "h-9 w-9 rounded" %> + <% end %> + <% else %> + + + <% sign_in_sign_up_path = Clearance.configuration.allow_sign_up? ? sign_up_path : sign_in_path %> + <%= link_to sign_in_sign_up_path, class: "-mr-1 p-1 md:hidden text-neutral-800 dark:text-white hover:text-neutral-600 dark:hover:text-neutral-400 " do %> + <%= icon_tag "account-box", size: 7 %> + <% end %> + <% end %> + + +
        + +
        + +
        +

        + <%= icon_tag "logo", size: 8, class: "w-6 h-6" %> + RubyGems +

        + + +

        + + + + <%= link_to t('sign_out'), sign_out_path, method: :delete, class: "w-full my-8 px-4 py-2 text-b2 rounded border border-black dark:border-white bg-white dark:bg-black hover:bg-neutral-100 dark:hover:bg-neutral-800 text-black dark:text-white text-center" %> +
        +
        +
        +
        diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4c75e93fe66..0c4f46be8b1 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -13,9 +13,7 @@ - - <%= stylesheet_link_tag("application") %> - <%= stylesheet_link_tag("tailwind", "data-turbo-track": "reload") %> + <%= stylesheet_link_tag("application", "preload_links_header" => true) %> <%= render "layouts/feeds" %> @@ -75,12 +73,12 @@ <%= link_to t('.header.settings'), edit_settings_path, class: "header__nav-link" %> <%= link_to t('.header.dashboard'), dashboard_path, class: "header__nav-link" %> <%= link_to t('.header.edit_profile'), edit_profile_path, class: "header__nav-link" %> - <%= link_to t('.header.sign_out'), sign_out_path, method: :delete, class: "header__nav-link" %> + <%= link_to t('sign_out'), sign_out_path, method: :delete, class: "header__nav-link" %>
        <% else %> - <%= link_to t('.header.sign_in'), sign_in_path, class: "header__nav-link #{'is-active' if request.path_info == '/sign_in'}" %> + <%= link_to t('sign_in'), sign_in_path, class: "header__nav-link #{'is-active' if request.path_info == '/sign_in'}" %> <% if Clearance.configuration.allow_sign_up? %> - <%= link_to t('.header.sign_up'), sign_up_path, class: "header__nav-link #{'is-active' if request.path_info == '/sign_up'}" %> + <%= link_to t('sign_up'), sign_up_path, class: "header__nav-link #{'is-active' if request.path_info == '/sign_up'}" %> <% end %> <% end %> diff --git a/app/views/layouts/hammy.html.erb b/app/views/layouts/hammy.html.erb new file mode 100644 index 00000000000..8c8ed47549a --- /dev/null +++ b/app/views/layouts/hammy.html.erb @@ -0,0 +1,251 @@ + + + + <%= page_title %> + + + + + <% ["57x57", "72x72", "76x76", "114x114","120x120", "144x144", "152x152", "180x180"].each do |size| %> + + <% end %> + + + + + + <%= stylesheet_link_tag("hammy") %> + <%= stylesheet_link_tag("tailwind", "data-turbo-track": "reload") %> + + + <%= render "layouts/feeds" %> + <%= csrf_meta_tag %> + <%= yield :head %> + <%= javascript_importmap_tags %> + + +
        + +
        + +
        +
        + +
        + + + + + <%= link_to(root_path, title: "RubyGems", class: "flex h-8 items-center text-orange text-b1") do %> + <%= icon_tag "logo", size: 8, class: "w-7 h-7 lg:w-8 lg:h-8", aria: { label: "RubyGems Home" } %> + + <% end %> + + + + + + +
        + +
        + + + + <%= link_to(root_path, title: "RubyGems", class: "flex h-8 items-center text-orange text-b1") do %> + <%= icon_tag "logo", size: 8, class: "w-7 h-7 lg:w-8 lg:h-8", aria: { label: "RubyGems Home" } %> + RubyGems + <% end %> +
        + + + +
        +
        +
        + + +
        + + + + + <%= render "layouts/session" %> +
        + + + +
        +
        + + + <%= render "layouts/breadcrumbs" %> +
        + + + <% if content_for?(:main) %> + <%= yield :main %> + <% else %> +
        +
        + + <%= render AlertComponent.new(style: :neutral, closeable: true) do %> + Design Under Construction. + Learn more + <% end %> + + <% flash.each do |name, msg| %> + <%= render AlertComponent.new(style: name, closeable: true) do %> + <%= flash_message(name, msg) %> + <% end %> + <% end %> + + <%= yield %> +
        +
        + <% end %> + + + +
        + + diff --git a/app/views/layouts/onboarding.html.erb b/app/views/layouts/onboarding.html.erb new file mode 100644 index 00000000000..42f82fef964 --- /dev/null +++ b/app/views/layouts/onboarding.html.erb @@ -0,0 +1,31 @@ +<% content_for :main do %> +
        +
        + <% flash.each do |name, msg| %> + <%= render AlertComponent.new(style: name, closeable: true) do %> + <%= flash_message(name, msg) %> + <% end %> + <% end %> +
        +
        + + <%# At desktop width, these outer 2 divs make the full content background with 2 columns of content %> +
        +

        Create an Organization

        +
        + + <%# mobile: main makes a full width section with a max width inner container %> +
        +
        + <%= yield %> +
        +
        + + <%# At mobile width, the aside is hidden %> + +
        +
        +<% end %> +<%= render template: "layouts/hammy" %> diff --git a/app/views/layouts/subject.html.erb b/app/views/layouts/subject.html.erb new file mode 100644 index 00000000000..64f01248ec2 --- /dev/null +++ b/app/views/layouts/subject.html.erb @@ -0,0 +1,42 @@ +<%# + This is a subject focused layout, like a profile or organization where + the user or organization stays on the left side while the main content changes. + On mobile, the subject connects to the header in color and spacing, making + it clear that the subject content is part of the page context. +%> +<% content_for :main do %> +
        +
        + + <%= render AlertComponent.new(style: :neutral, closeable: true) do %> + Design Under Construction. + Learn more + <% end %> + + <% flash.each do |name, msg| %> + <%= render AlertComponent.new(style: name, closeable: true) do %> + <%= flash_message(name, msg) %> + <% end %> + <% end %> +
        +
        + + <%# At desktop width, these outer 2 divs make the full content background with 2 columns of content %> +
        +
        + <%# At mobile width, the inner aside and main make two stacked full width sections %> + + +
        +
        + <%= yield %> +
        +
        +
        +
        +<% end %> +<%= render template: "layouts/hammy" %> diff --git a/app/views/mailer/admin_manual.html.erb b/app/views/mailer/admin_manual.html.erb new file mode 100644 index 00000000000..e53371b916d --- /dev/null +++ b/app/views/mailer/admin_manual.html.erb @@ -0,0 +1,20 @@ +<% @title = t(".title") %> + + + + + + + + +
        +
         
        + +
        + <%= simple_format @body %>
        +
        + +
         
        + +
        + diff --git a/app/views/mailer/admin_manual.text.erb b/app/views/mailer/admin_manual.text.erb new file mode 100644 index 00000000000..9e66ef58e6d --- /dev/null +++ b/app/views/mailer/admin_manual.text.erb @@ -0,0 +1,3 @@ +Hi <%= @user.handle %> + +<%= strip_tags @body %> diff --git a/app/views/multifactor_auths/recovery.html.erb b/app/views/multifactor_auths/recovery.html.erb index f1e46bec240..9a5fa4cbe06 100644 --- a/app/views/multifactor_auths/recovery.html.erb +++ b/app/views/multifactor_auths/recovery.html.erb @@ -1,18 +1,25 @@ <% @title = t(".title") %> -
        +<%= tag.div( + class: "t-body", + data: { + controller: "clipboard", + clipboard_success_content_value: t('copied') + } +) do %>

        <%= t ".note_html" %>

        -
          - <% @mfa_recovery_codes.each do |code| %> -
        • <%= code %>
        • - <% end %> -
        + <%# This tag contains the recovery codes and should not be a part of the form %> + <%= text_area_tag "source", "#{@mfa_recovery_codes.join("\n")}\n", class: "recovery-code-list", rows: @mfa_recovery_codes.size + 1, cols: @mfa_recovery_codes.first.length + 1, readonly: true, data: { clipboard_target: "source" } %> -

        <%= link_to t(".copy"), "#/", class: "t-link--bold recovery__copy__icon", data: { "clipboard-target": "#recovery-code-list" } %>

        -
        - <%= check_box_tag "checked", "ack", false, class: "form__checkbox__input" %> - <%= label_tag "checked", t(".saved"), class: "form__checkbox__label" %> -
        - <%= button_to t(".continue"), @continue_path, method: "get", class: "form__submit form__submit--no-hover", disabled: true %> -
        + <%= form_tag(@continue_path, method: "get", class: "form", data: { controller: "recovery", recovery_confirm_value: t(".confirm_dialog"), action: "recovery#submit" }) do %> +

        <%= link_to t("copy_to_clipboard"), "#/", class: "t-link--bold recovery__copy__icon", data: { action: "clipboard#copy recovery#copy", clipboard_target: "button" } %>

        + +
        + <%= check_box_tag "checked", "ack", false, required: true, class: "form__checkbox__input" %> + <%= label_tag "checked", t(".saved"), class: "form__checkbox__label" %> +
        + + <%= button_tag t(".continue"), class: "form__submit form__submit--no-hover" %> + <% end %> +<% end %> diff --git a/app/views/oidc/api_key_roles/github_actions_workflow_view.rb b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb index a7032b28e00..c250501599c 100644 --- a/app/views/oidc/api_key_roles/github_actions_workflow_view.rb +++ b/app/views/oidc/api_key_roles/github_actions_workflow_view.rb @@ -15,7 +15,7 @@ def view_template return if not_configured - div(class: "t-body") do + div(class: "t-body", data: { controller: "clipboard", clipboard_success_content_value: "✔" }) do p do t(".configured_for_html", link_html: single_gem_role? ? helpers.link_to(gem_name, rubygem_path(gem_name)) : t(".a_gem")) @@ -30,12 +30,14 @@ def view_template header(class: "gem__code__header") do h3(class: "t-list__heading l-mb-0") { code { ".github/workflows/push_gem.yml" } } - button(class: "gem__code__icon", data: { "clipboard-target": "#workflow_yaml" }) { "=" } - span(class: "gem__code__tooltip--copy") { t("copy_to_clipboard") } - span(class: "gem__code__tooltip--copied") { t("copied") } + button( + class: "gem__code__icon", + title: t("copy_to_clipboard"), + data: { action: "click->clipboard#copy", clipboard_target: "button" } + ) { "=" } end pre(class: "gem__code multiline") do - code(class: "multiline", id: "workflow_yaml") do + code(class: "multiline", id: "workflow_yaml", data: { clipboard_target: "source" }) do plain workflow_yaml end end diff --git a/app/views/oidc/id_tokens/show_view.rb b/app/views/oidc/id_tokens/show_view.rb index 995222b359d..a151197509c 100644 --- a/app/views/oidc/id_tokens/show_view.rb +++ b/app/views/oidc/id_tokens/show_view.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true class OIDC::IdTokens::ShowView < ApplicationView - extend Dry::Initializer include Phlex::Rails::Helpers::TimeTag include Phlex::Rails::Helpers::LinkTo - option :id_token + prop :id_token, reader: :public def view_template # rubocop:disable Metrics/AbcSize self.title = t(".title") diff --git a/app/views/oidc/pending_trusted_publishers/index_view.rb b/app/views/oidc/pending_trusted_publishers/index_view.rb index 60343a55cd9..903f15db369 100644 --- a/app/views/oidc/pending_trusted_publishers/index_view.rb +++ b/app/views/oidc/pending_trusted_publishers/index_view.rb @@ -5,9 +5,8 @@ class OIDC::PendingTrustedPublishers::IndexView < ApplicationView include Phlex::Rails::Helpers::ContentFor include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow include Phlex::Rails::Helpers::LinkTo - extend Dry::Initializer - option :trusted_publishers + prop :trusted_publishers, reader: :public def view_template title_content diff --git a/app/views/oidc/pending_trusted_publishers/new_view.rb b/app/views/oidc/pending_trusted_publishers/new_view.rb index e9485d61e4a..76b2b259b02 100644 --- a/app/views/oidc/pending_trusted_publishers/new_view.rb +++ b/app/views/oidc/pending_trusted_publishers/new_view.rb @@ -6,9 +6,7 @@ class OIDC::PendingTrustedPublishers::NewView < ApplicationView include Phlex::Rails::Helpers::OptionsForSelect include Phlex::Rails::Helpers::FormWith - extend Dry::Initializer - - option :pending_trusted_publisher + prop :pending_trusted_publisher, reader: :public def view_template self.title = t(".title") diff --git a/app/views/oidc/rubygem_trusted_publishers/index_view.rb b/app/views/oidc/rubygem_trusted_publishers/index_view.rb index 3619836663f..42816591196 100644 --- a/app/views/oidc/rubygem_trusted_publishers/index_view.rb +++ b/app/views/oidc/rubygem_trusted_publishers/index_view.rb @@ -5,10 +5,9 @@ class OIDC::RubygemTrustedPublishers::IndexView < ApplicationView include Phlex::Rails::Helpers::LinkTo include Phlex::Rails::Helpers::ContentFor include OIDC::RubygemTrustedPublishers::Concerns::Title - extend Dry::Initializer - option :rubygem - option :trusted_publishers + prop :rubygem, reader: :public + prop :trusted_publishers, reader: :public def view_template title_content diff --git a/app/views/oidc/rubygem_trusted_publishers/new_view.rb b/app/views/oidc/rubygem_trusted_publishers/new_view.rb index f4280aa771a..0c976a06ae9 100644 --- a/app/views/oidc/rubygem_trusted_publishers/new_view.rb +++ b/app/views/oidc/rubygem_trusted_publishers/new_view.rb @@ -8,9 +8,7 @@ class OIDC::RubygemTrustedPublishers::NewView < ApplicationView include Phlex::Rails::Helpers::SelectTag include OIDC::RubygemTrustedPublishers::Concerns::Title - extend Dry::Initializer - - option :rubygem_trusted_publisher + prop :rubygem_trusted_publisher, reader: :public def view_template title_content diff --git a/app/views/organizations/onboarding/_progress.html.erb b/app/views/organizations/onboarding/_progress.html.erb new file mode 100644 index 00000000000..0b6455ea67f --- /dev/null +++ b/app/views/organizations/onboarding/_progress.html.erb @@ -0,0 +1,6 @@ +<%= render Onboarding::StepsComponent.new(current_step) do |s| %> + <%= s.step "Name", organization_onboarding_name_path %> + <%= s.step "Gems", organization_onboarding_gems_path %> + <%= s.step "Members", organization_onboarding_users_path %> + <%= s.step "Finalize", organization_onboarding_confirm_path %> +<% end %> diff --git a/app/views/organizations/onboarding/_summary.html.erb b/app/views/organizations/onboarding/_summary.html.erb new file mode 100644 index 00000000000..30f5a3b2c7d --- /dev/null +++ b/app/views/organizations/onboarding/_summary.html.erb @@ -0,0 +1,71 @@ +<% current_step ||= 1 %> + + +
        +

        Summary

        +
        + +<%# Name Section %> +
        +
        +

        Name

        + <% if current_step > 1 %> + <%= link_to "edit", organization_onboarding_name_path, class: "text-orange-500" %> + <% end %> +
        +
        +

        + Org handle + <%= @organization_onboarding.organization_handle %> +

        +

        + Org name + <%= @organization_onboarding.organization_name %> +

        +
        +
        +
        + +<%# Gems Section %> +
        +
        +

        + Gems + <%= @organization_onboarding.selected_rubygems.size %> +

        + <% if current_step > 2 %> + <%= link_to "edit", organization_onboarding_gems_path, class: "text-orange-500" %> + <% end %> +
        +
          + <% @organization_onboarding.selected_rubygems.each do |gem| %> +
        • <%= gem.name %>
        • + <% end %> +
        +
        +
        + +<%# People Section %> +
        +
        +

        + People + <%= approved_invites.size %> +

        + <% if current_step > 3 %> + <%= link_to "edit", organization_onboarding_users_path, class: "text-orange-500" %> + <% end %> +
        +
          + <% approved_invites.each do |invite| %> + <% user = invite.user %> +
        • + + <%= avatar 48, "gravatar-#{user.id}", user, theme: :dark, class: "h-6 w-6 rounded mr-2 inline-block" %> + <%= user.handle %> + + <%= invite.role %> +
        • + <% end %> +
        +
        diff --git a/app/views/organizations/onboarding/confirm/edit.html.erb b/app/views/organizations/onboarding/confirm/edit.html.erb new file mode 100644 index 00000000000..ad5c9540f08 --- /dev/null +++ b/app/views/organizations/onboarding/confirm/edit.html.erb @@ -0,0 +1,17 @@ +<%= render "organizations/onboarding/progress", current_step: 4 %> + +

        Finalize

        + +

        Review the summary and confirm everything looks good.

        +

        Your Org handle is permanent, and will require support to update beyond this point. Everything else can be updated any time.

        + +
        + <%= render "organizations/onboarding/summary", current_step: 4 %> +
        + +<%= form_with(model: @organization_onboarding, url: organization_onboarding_confirm_path, method: :patch) do |form| %> +
        + <%= render ButtonComponent.new("Back", organization_onboarding_users_path, type: :link, color: :neutral, style: :outline) %> + <%= render ButtonComponent.new("Create Org", type: :submit, style: :fill) %> +
        +<% end %> diff --git a/app/views/organizations/onboarding/gems/edit.html.erb b/app/views/organizations/onboarding/gems/edit.html.erb new file mode 100644 index 00000000000..0e93f074b28 --- /dev/null +++ b/app/views/organizations/onboarding/gems/edit.html.erb @@ -0,0 +1,56 @@ +<% content_for :aside do %> + <%= render "organizations/onboarding/summary", current_step: 2 %> +<% end %> + +<%= render "organizations/onboarding/progress", current_step: 2 %> + +

        Add gems to your Org

        + +<%= form_with(model: @organization_onboarding, url: organization_onboarding_gems_path, method: :patch, data: { controller: "onboarding-gems counter" }) do |form| %> +
        +
        +

        Gems<%= @organization_onboarding.rubygems.size %>

        + +
        +
          + <% available_rubygems.each do |gem| %> + <% required = @organization_onboarding.namesake_rubygem == gem %> + +
        • + +
        • + <% end %> +
        +
        + +
        + <%= render ButtonComponent.new("Back", organization_onboarding_name_path, type: :link, color: :neutral, style: :outline) %> + <%= render ButtonComponent.new("Continue", type: :submit, style: :outline) %> +
        +<% end %> diff --git a/app/views/organizations/onboarding/name/new.html.erb b/app/views/organizations/onboarding/name/new.html.erb new file mode 100644 index 00000000000..54c05540067 --- /dev/null +++ b/app/views/organizations/onboarding/name/new.html.erb @@ -0,0 +1,96 @@ +<% content_for :aside do %> + <%= render "organizations/onboarding/summary", current_step: 1 %> +<% end %> + +<%= render "organizations/onboarding/progress", current_step: 1 %> + +

        Name your Organization

        + +

        + Choose a name for your Organization. +
        + This name will be used as the URL for your Organization's page and will eventually become its namespace. +

        + +<%= form_with(model: @organization_onboarding, url: organization_onboarding_name_path, method: :post, data: { controller: "onboarding-name" }) do |form| %> +

        Name your org using:

        + +
        + + <%# Username option is disabled at first %> + +
        + + + + + + + + +
        + <%= render ButtonComponent.new("Cancel", organization_onboarding_path, type: :link, color: :neutral, style: :outline, method: :delete) %> + <%= render ButtonComponent.new("Continue", type: :submit, style: :outline, data: { onboarding_name_target: "submit" }) %> +
        +<% end %> diff --git a/app/views/organizations/onboarding/users/edit.html.erb b/app/views/organizations/onboarding/users/edit.html.erb new file mode 100644 index 00000000000..0f1be2b5b93 --- /dev/null +++ b/app/views/organizations/onboarding/users/edit.html.erb @@ -0,0 +1,96 @@ +<% content_for :aside do %> + <%= render "organizations/onboarding/summary", current_step: 3 %> +<% end %> + +<%= render "organizations/onboarding/progress", current_step: 3 %> + +

        Manage Members

        + +

        + Below we have listed all the owners of the gems you selected. + If you do nothing, these existing owners will remain owners of their gems. + You can invite them to join the organization now or you can add members later. +

        + +
        +

        + <%= render ButtonComponent.new(type: :button, color: :neutral, style: :plain, data: { action: "reveal#toggle" }) do %> + <%= icon_tag "keyboard-arrow-down", class:"transition-transform transform mr-1", data: { reveal_target: "toggle" } %> + <%= "Permission Details" %> + <% end %> +

        + +
        +
        + Outside Contributor +
        +
        +

        Outside Contributors are non-members that maintain ownership of their gems in this org.

        +

        + * If you make no changes, gem ownership will remain unaltered for everyone else. +

        +
        + +
        + Maintainer +
        +
        +

        Maintainers can publish and manage any gem owned by the organization

        +
        + +
        + Admin +
        +
        +

        Admins can publish and manage any gem owned by the organization and:

        +

        Manage organization teams, outside contributors and individual gems.

        +

        + * An admin cannot remove a gem from the organization or manage organization settings. +

        +
        + +
        + Owner +
        +
        + Owners can add other owners to the organization and remove gems from the organization. +
        +
        +
        + +<%= form_with(model: @organization_onboarding, url: organization_onboarding_users_path, method: :patch) do |form| %> +
          +
        • + + <%= avatar 48, "gravatar-#{@organization_onboarding.created_by.id}", theme: :dark, class: "h-6 w-6 rounded mr-2" %> + <%= @organization_onboarding.created_by.handle %> + + Owner +
        • + <%= form.fields_for :invites do |invite_fields| %> + <% user = invite_fields.object.user %> +
        • + <%= invite_fields.label :role, class: "flex flex-row items-center h-11" do %> + <%= avatar 48, "gravatar-#{user.id}", user, theme: :dark, class: "h-6 w-6 rounded mr-2" %> + <%= user.handle %> + <% end %> + <%= invite_fields.select( + :role, + role_options, + { include_blank: "select role" }, + style: "text-align-last: right;", + class: "text-b2 rounded-lg appearance-none outline-none bg-white dark:bg-black border-none h-11 text-right focus:ring-2 focus:ring-inset focus:ring-orange" + ) %> + <%= invite_fields.hidden_field :id %> +
        • + <% end %> +
        + +
        + <%= render ButtonComponent.new("Back", organization_onboarding_gems_path, type: :link, color: :neutral, style: :outline) %> + + <%= render ButtonComponent.new("Skip for now", organization_onboarding_confirm_path, type: :link, color: :neutral, style: :plain) %> + <%= render ButtonComponent.new("Continue", type: :submit, style: :outline) %> + +
        +<% end %> diff --git a/app/views/owners/_owners_table.html.erb b/app/views/owners/_owners_table.html.erb index ec78a4160d5..4ad86899233 100644 --- a/app/views/owners/_owners_table.html.erb +++ b/app/views/owners/_owners_table.html.erb @@ -13,6 +13,9 @@ <%= t("owners.index.added_by") %> + + <%= t("owners.index.role") %> + <%= t("owners.index.confirmed_at") %> @@ -40,6 +43,9 @@ <%= ownership.authorizer_name %> + + <%= Ownership.human_attribute_name("role.#{ownership.role}") %> + <%= ownership.confirmed_at.strftime("%Y-%m-%d %H:%M %Z") if ownership.confirmed? %> @@ -49,6 +55,12 @@ method: "delete", data: { confirm: t("owners.index.confirm_remove") }, class: "form__submit form__submit--small" %> +
        + <%= button_to "Edit", + edit_rubygem_owner_path(@rubygem.name, ownership.user.display_id), + disabled: ownership.user == current_user, + method: "get", + class: "form__submit form__submit--small" %> <% end %> diff --git a/app/views/owners/edit.html.erb b/app/views/owners/edit.html.erb new file mode 100644 index 00000000000..e1e7bfcd9d4 --- /dev/null +++ b/app/views/owners/edit.html.erb @@ -0,0 +1,21 @@ +<% @title = t('.title') %> +<% @roles = Ownership.roles.map { |k,_| [Ownership.human_attribute_name("role.#{k}"), k] } %> + +<%= form_tag rubygem_owner_path(rubygem_id: @rubygem.slug, owner_id: @ownership.user.display_id), method: :patch do |form| %> + <%= error_messages_for(@ownership) %> + +
        + <%= label_tag :display_id, "User", class: 'form__label' %> + <%= text_field_tag :display_id, @ownership.user.display_id, disabled: true, :class => 'form__input' %> +
        + +
        + <%= label_tag :role, t(".role"), class: 'form__label' %> +
        + <%= select_tag :role, options_for_select(@roles, @ownership.role), class: "form__input form__select" %> +
        + +
        + <%= submit_tag 'Update', :data => {:disable_with => t('form_disable_with')}, :class => 'form__submit' %> +
        +<% end %> diff --git a/app/views/owners/index.html.erb b/app/views/owners/index.html.erb index 75bc1084b47..957f5a24daa 100644 --- a/app/views/owners/index.html.erb +++ b/app/views/owners/index.html.erb @@ -1,5 +1,6 @@ <% @title = @rubygem.name %> <% @subtitle = t(".info", gem: @rubygem.name) %> +<% @roles = Ownership.roles.map { |k,_| [Ownership.human_attribute_name("role.#{k}"), k] } %>
        <%= render "owners_table" %> @@ -14,6 +15,11 @@ <%= label_tag :handle, t(".email_field"), class: "form__label" %> <%= text_field_tag :handle, nil, class: "form__input", required: true %>
        +
        + <%= label_tag :role, t(".role_field"), class: "form__label" %> +
        + <%= select_tag :role, options_for_select(@roles), class: "form__input form__select" %> +
        <%= submit_tag t(".submit_button"), data: {disable_with: t("form_disable_with")}, class: "form__submit" %>
        diff --git a/app/views/owners_mailer/owner_updated.html.erb b/app/views/owners_mailer/owner_updated.html.erb new file mode 100644 index 00000000000..9c7a8a1e286 --- /dev/null +++ b/app/views/owners_mailer/owner_updated.html.erb @@ -0,0 +1,40 @@ +<% @title = t("mailer.owner_updated.title") %> +<% @sub_title = t("mailer.owner_updated.subtitle", user_handle: @user.handle) %> + + + + + + + + +
        +
        +
        +

        + <%= t("mailer.owner_updated.body_html", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY, role: Ownership.human_attribute_name("role.#{@ownership.role}")) %> +

        +
         
        + +

        If this change is expected, you do not need to take further action.

        +

        + Only if this change is unexpected + please take immediate steps to secure your account and gems: +

        + + <%= render "mailer/compromised_instructions" do %> +
      • Look out for unexpected changes to your gems on RubyGems.org
      • + <% end %> + +

        + + To stop receiving these messages, update your <%= link_to("email notification settings", notifier_url) %>. + +

        +
        + +
         
        + +
         
        + +
        diff --git a/app/views/owners_mailer/owner_updated.text.erb b/app/views/owners_mailer/owner_updated.text.erb new file mode 100644 index 00000000000..b68561ccfd8 --- /dev/null +++ b/app/views/owners_mailer/owner_updated.text.erb @@ -0,0 +1,5 @@ +<%= t("mailer.owner_updated.subtitle", user_handle: @user.handle) %> + +<%= t("mailer.owner_updated.body_text", gem: @rubygem.name, host: Gemcutter::HOST_DISPLAY, role: Ownership.human_attribute_name("role.#{@ownership.role}")) %> + +<%= rubygem_owners_url(@rubygem.slug) %> diff --git a/app/views/ownership_calls/_close.html.erb b/app/views/ownership_calls/_close.html.erb index af7ddc1001f..1d9b5e119a4 100644 --- a/app/views/ownership_calls/_close.html.erb +++ b/app/views/ownership_calls/_close.html.erb @@ -1,3 +1,3 @@ -<% if ownership_call.rubygem.owned_by?(current_user) %> +<% if policy(ownership_call).close? %>
        <%= button_to t("ownership_calls.close"), close_rubygem_ownership_calls_path(ownership_call.rubygem.slug), method: :patch, class: "form__submit form__submit--medium" %>
        <% end %> diff --git a/app/views/ownership_requests/_list.html.erb b/app/views/ownership_requests/_list.html.erb index a0cdd088204..4481cf4ec4a 100644 --- a/app/views/ownership_requests/_list.html.erb +++ b/app/views/ownership_requests/_list.html.erb @@ -4,7 +4,7 @@

      -<% if @rubygem.owned_by?(current_user) %> +<% if policy(@rubygem).manage_adoption? %>
      <%= render(partial: "ownership_requests/owner", layout: "ownership_requests/ownership_request", collection: @ownership_requests, as: :ownership_request, locals: { show_user: true, show_gem: false }) || t("ownership_requests.no_ownership_requests", gem: @rubygem.name) %>
      diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb index 115142f8cad..e654a2f3e20 100644 --- a/app/views/pages/about.html.erb +++ b/app/views/pages/about.html.erb @@ -1,11 +1,11 @@ <% @title = t('.title') %> -
      +<%= prose do %>

      <%= t '.purpose.header', :site => t('title') %>

      -
        -
      1. <%= t '.purpose.better_api' %>
      2. -
      3. <%= t '.purpose.transparent_pages' %>
      4. -
      5. <%= t '.purpose.enable_community' %>
      6. +
          +
        1. <%= t '.purpose.better_api' %>
        2. +
        3. <%= t '.purpose.transparent_pages' %>
        4. +
        5. <%= t '.purpose.enable_community' %>

        <%= t('.founding_html', :founder => link_to('Nick Quaranto', 'https://twitter.com/qrush'), @@ -28,16 +28,9 @@ ) %>

        -
        -

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

        -

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

        - +

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

        +

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

        +
        + <%= render ButtonComponent.new t('rubygems.aside.links.download'), "/logos.zip", type: :link %>
        -
        +<% end %> diff --git a/app/views/pages/data.html.erb b/app/views/pages/data.html.erb index b3d820b82ba..c447164d503 100644 --- a/app/views/pages/data.html.erb +++ b/app/views/pages/data.html.erb @@ -1,10 +1,19 @@ <% @title = t('.title') %> -
        -

        We provide weekly dumps of the RubyGems.org PostgreSQL data. This dump is sanitized, removing all user information.

        -

        The load-pg-dump script is - available as an example of how to to download and load the most recent weekly dump.

        -
          -

          We also provide weekly dumps of the historial RubyGems.org Redis data. (We do not use redis anymore but these are here for historical purposes.)

          -
            +
            + <%= render CardComponent.new do |c| %> + <%= c.head("PostgreSQL Data", icon: :history) %> + <%= prose do |user| %> +

            We provide weekly dumps of the RubyGems.org PostgreSQL data. This dump is sanitized, removing all user information.

            +

            The load-pg-dump script is + available as an example of how to to download and load the most recent weekly dump.

            + <% end %> + <%= c.list(data: { controller: "dump" }) do %> + + <% end %> + <% end %>
            diff --git a/app/views/pages/download.html.erb b/app/views/pages/download.html.erb index 0cecaa6c9db..e1412a7237c 100644 --- a/app/views/pages/download.html.erb +++ b/app/views/pages/download.html.erb @@ -1,30 +1,34 @@ <% @title = t('.title') %> -<% @subtitle = subtitle %> -
            +<%= prose do %> +

            <%= @title %>

            + <% if (rubygems_version = Rubygem.current_rubygems_release) %> +

            <%= "v#{rubygems_version.number} - #{nice_date_for(rubygems_version.authored_at)}" %>

            + <% end %>

            - RubyGems is a package management framework for Ruby. Download the latest version here: -

            -
            - <%= link_to "tgz", "https://rubygems.org/rubygems/rubygems-#{version_number}.tgz", class: "download__format" %> - <%= link_to "zip", "https://rubygems.org/rubygems/rubygems-#{version_number}.zip", class: "download__format" %> - <%= link_to "gem", "https://rubygems.org/gems/rubygems-update-#{version_number}.gem", class: "download__format" %> - <%= link_to "git", "https://github.com/rubygems/rubygems", class: "download__format" %> -
            -

            - Or, to upgrade to the latest RubyGems: + RubyGems is a package management framework for Ruby. +
            + Upgrade to the latest RubyGems at the command line:

            $ gem update --system

            - You might be running into some bug that prevents you from upgrading rubygems the standard way. In that case, you can try upgrading manually: + If you run into problems that prevent you from upgrading rubygems the standard way, you can try upgrading manually:

              -
            1. <%=link_to "Download from above", "#formats" %>
            2. -
            3. Unpack into a directory and cd there
            4. -
            5. Install with: ruby setup.rb
            6. +
            7. Download the latest version
            8. +
            9. Unpack and cd into the unpacked directory
            10. +
            11. Install with: +
              ruby setup.rb
              +
            12. +
            13. + For more details and other options, run: ruby setup.rb --help +
            -

            - For more details and other options, see: -

            -
            ruby setup.rb --help
            -
            +

            Download the latest version

            +
            + <%= render ButtonComponent.new "tgz", "https://rubygems.org/rubygems/rubygems-#{rubygems_version}.tgz", type: :link %> + <%= render ButtonComponent.new "zip", "https://rubygems.org/rubygems/rubygems-#{rubygems_version}.zip", type: :link %> + <%= render ButtonComponent.new "gem", "https://rubygems.org/gems/rubygems-update-#{rubygems_version}.gem", type: :link %> + <%= render ButtonComponent.new "git", "https://github.com/rubygems/rubygems", type: :link %> +
            +<% end %> diff --git a/app/views/pages/faq.html.erb b/app/views/pages/faq.html.erb deleted file mode 100644 index ab8e903c4df..00000000000 --- a/app/views/pages/faq.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<% @title = t('.title') %> - -
            -

            - <%= link_to "This page has been moved onto #{t(:title)}’s new support site.", "https://help.rubygems.org/kb/gemcutter/faq" %> -

            -
            diff --git a/app/views/pages/migrate.html.erb b/app/views/pages/migrate.html.erb deleted file mode 100644 index aac2e406081..00000000000 --- a/app/views/pages/migrate.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% @title = t('.title') %> - -
            -

            - gem migrate has been deprecated, all of the RubyForge accounts and ownerships have been transferred over to Gemcutter. -

            -

            If you’re having problems with your gems and pushing, <%= mail_to "support@rubygems.org", "please open a support request" %>.

            -
            diff --git a/app/views/pages/security.html.erb b/app/views/pages/security.html.erb index e7731de0096..2955948a383 100644 --- a/app/views/pages/security.html.erb +++ b/app/views/pages/security.html.erb @@ -1,6 +1,6 @@ <% @title = t('.title') %> -
            +<%= prose do %>

            Found a security issue with RubyGems or RubyGems.org? Please follow these steps to report it. @@ -131,4 +131,4 @@ security@rubygems.org or open an issue on GitHub. Thanks!

            -
            +<% end %> diff --git a/app/views/pages/sponsors.html.erb b/app/views/pages/sponsors.html.erb index 7d93b9c5096..05b78bf813e 100644 --- a/app/views/pages/sponsors.html.erb +++ b/app/views/pages/sponsors.html.erb @@ -1,6 +1,6 @@ <% @title = t('.title') %> -
            +<%= prose do %>

            RubyGems.org is made possible by support from the Ruby community. These companies provide the servers and developers that create the public infrastructure of the Ruby programming language.

            @@ -8,10 +8,11 @@

            Ruby Central

            Ruby Central, Inc. is a nonprofit 501(c)3 organization dedicated to the support and advocacy of the worldwide Ruby community. They organize the annual RubyConf and RailsConf as opportunities for Rubyists to collaborate and network. As part of those conferences, Ruby Central also operates the Opportunity Scholarship program to consistently bring new people into the tech community and to provide them with a comfortable, welcoming environment to explore our community and its events. -

            +

            +

            They also fund and operate the Ruby Central Community Grant, underwriting events or open source projects that benefit the Ruby community, and they fund the ongoing server costs associated with running RubyGems.org. - You can support Ruby Central by attending or [sponsoring](sponsors@rubycentral.org) a conference, or by [joining as a supporting member](https://rubycentral.org/#/portal/signup). + You can support Ruby Central by attending or contacting Ruby Central about sponsoring a conference, or by joining as a supporting member.

            Fastly

            @@ -21,16 +22,12 @@ Fastly provides the RubyGems.org content delivery network, allowing Ruby developers all over the world to download gems from nearby servers around the world.

            -
            -

            1Password

            A password manager, digital vault, form filler and secure digital wallet. 1Password remembers all your passwords for you to help keep account information safe.

            -
            -

            Avo

            Avo is a custom Content Management System for Ruby on Rails that saves developers and teams months of development time. It's built on modern technologies and provides all the necessary hooks to ensure developers ship the best experiences to their customers.

            Avo enables the RubyGems.org team to quickly build internal tools with limited resources.

            -
            +<% end %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 18a49cb6f8a..e5e5bd17d08 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,7 +1,7 @@ <% @title = t('.title') %> <%= form_for(:password_reset, url: password_path, html: { method: :put }) do |form| %> - <%= error_messages_for current_user %> + <%= error_messages_for @user %>
            <%= form.label :password, "Password", :class => 'form__label' %> <%= form.password_field :password, autocomplete: 'new-password', class: 'form__input' %> diff --git a/app/views/profiles/security_events_view.rb b/app/views/profiles/security_events_view.rb index 31e4f133609..46cbd8a78e7 100644 --- a/app/views/profiles/security_events_view.rb +++ b/app/views/profiles/security_events_view.rb @@ -6,9 +6,8 @@ class Profiles::SecurityEventsView < ApplicationView include Phlex::Rails::Helpers::DistanceOfTimeInWordsToNow include Phlex::Rails::Helpers::TimeTag include Phlex::Rails::Helpers::LinkTo - extend Dry::Initializer - option :security_events + prop :security_events, reader: :public def view_template title_content diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb index d53b7f5ca94..a109e68018b 100644 --- a/app/views/profiles/show.html.erb +++ b/app/views/profiles/show.html.erb @@ -107,7 +107,7 @@

            - <%= t('stats.index.total_downloads') %> + <%= t('total_downloads') %>

            diff --git a/app/views/rubygems/_aside.html.erb b/app/views/rubygems/_aside.html.erb index f12a8e7b4e9..f7735be703b 100644 --- a/app/views/rubygems/_aside.html.erb +++ b/app/views/rubygems/_aside.html.erb @@ -7,7 +7,7 @@ <% end %>

            - <%= t('stats.index.total_downloads') %> + <%= t('total_downloads') %> <%= number_with_delimiter(@rubygem.downloads) %>

            @@ -15,6 +15,14 @@ <%= number_with_delimiter(@latest_version.downloads_count) %>

            + +

            + <%= t('.gem_version_age') %>: + +

            <%= local_time_ago(@latest_version.authored_at) %>

            +
            +

            +

            <%= pluralized_licenses_header @latest_version %>: @@ -66,11 +74,11 @@ <%= atom_link(@rubygem) %> <%= report_abuse_link(@rubygem) %> <%= reverse_dependencies_link(@rubygem) %> - <%= ownership_link(@rubygem) if @rubygem.owned_by?(current_user) %> - <%= rubygem_trusted_publishers_link(@rubygem) if @rubygem.owned_by?(current_user) %> - <%= oidc_api_key_role_links(@rubygem) if @rubygem.owned_by?(current_user) %> + <%= ownership_link(@rubygem) if policy(@rubygem).show_unconfirmed_ownerships? %> + <%= rubygem_trusted_publishers_link(@rubygem) if policy(@rubygem).configure_trusted_publishers? %> + <%= oidc_api_key_role_links(@rubygem) if policy(@rubygem).configure_oidc? %> <%= resend_owner_confirmation_link(@rubygem) if @rubygem.unconfirmed_ownership?(current_user) %> <%= rubygem_adoptions_link(@rubygem) if policy(@rubygem).show_adoption? %> - <%= rubygem_security_events_link(@rubygem) if @rubygem.owned_by?(current_user) %> + <%= rubygem_security_events_link(@rubygem) if policy(@rubygem).show_events? %>

            diff --git a/app/views/rubygems/_gem_members.html.erb b/app/views/rubygems/_gem_members.html.erb index fc15a017930..d8457c7eec1 100644 --- a/app/views/rubygems/_gem_members.html.erb +++ b/app/views/rubygems/_gem_members.html.erb @@ -57,12 +57,7 @@ <% if latest_version.sha256.present? %>

            <%= t '.sha_256_checksum' %>:

            -
            - - = - <%= t('copy_to_clipboard') %> - <%= t('copied') %> -
            + <%= copy_field_tag("gem_sha_256_checksum", latest_version.sha256_hex) %> <% end %> <% if latest_version.cert_chain.present? %> @@ -75,4 +70,11 @@ <% end %>
          <% end %> + + <% if latest_version.attestations.present? %> +

          <%= t '.provenance_header' %>:

          + <% latest_version.attestations.each do |attestation| %> + <%= render Version::ProvenanceComponent.new(attestation:) %> + <%end%> + <% end %> diff --git a/app/views/rubygems/security_events_view.rb b/app/views/rubygems/security_events_view.rb index 79c2e01bd71..613c4c28b86 100644 --- a/app/views/rubygems/security_events_view.rb +++ b/app/views/rubygems/security_events_view.rb @@ -4,10 +4,9 @@ class Rubygems::SecurityEventsView < ApplicationView include Phlex::Rails::Helpers::ButtonTo include Phlex::Rails::Helpers::ContentFor include Phlex::Rails::Helpers::LinkTo - extend Dry::Initializer - option :rubygem - option :security_events + prop :rubygem, reader: :public + prop :security_events, reader: :public def view_template title_content diff --git a/app/views/rubygems/show.html.erb b/app/views/rubygems/show.html.erb index 617e95ac320..a1703a598b0 100644 --- a/app/views/rubygems/show.html.erb +++ b/app/views/rubygems/show.html.erb @@ -31,19 +31,11 @@

          <%= t '.bundler_header' %>: -
          - - = - <%= t('copy_to_clipboard') %> - <%= t('copied') %> -
          + <%= copy_field_tag("gemfile_text", @latest_version.to_bundler(locked_version: @on_version_page)) %>

          <%= t '.install' %>: -
          - - = -
          + <%= copy_field_tag("install_text", @latest_version.to_install) %>

          <% else %> diff --git a/app/views/searches/advanced.html.erb b/app/views/searches/advanced.html.erb index 68f7c62a262..0a5ccd43042 100644 --- a/app/views/searches/advanced.html.erb +++ b/app/views/searches/advanced.html.erb @@ -1,8 +1,16 @@ <% @title = t("advanced_search") %> -