diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1efb99ec1..e6ce4f9b0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,7 +12,7 @@ updates: registries: - github-basecamp directory: "/" - insecure-external-code-execution: allow + insecure-external-code-execution: allow # zizmor: ignore[dependabot-execution] -- required for Bundler to resolve gems from the private github-basecamp registry open-pull-requests-limit: 10 vendor: false groups: @@ -25,6 +25,10 @@ updates: semver-major-days: 14 - package-ecosystem: github-actions directory: "/" + groups: + github-actions: + patterns: + - "*" schedule: interval: weekly open-pull-requests-limit: 10 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 773674233..c410061c9 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -12,8 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true @@ -26,8 +28,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true @@ -41,17 +45,32 @@ jobs: - name: Brakeman audit run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error - lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true - name: Lint code for consistent style run: bin/rubocop + + zizmor: + name: GitHub Actions audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + advanced-security: false diff --git a/.github/workflows/dependabot-sync-saas-lockfile.yml b/.github/workflows/dependabot-sync-saas-lockfile.yml index 5afe97244..4191cd684 100644 --- a/.github/workflows/dependabot-sync-saas-lockfile.yml +++ b/.github/workflows/dependabot-sync-saas-lockfile.yml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] -- credentials needed for git push - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 953f09b72..dfbb70246 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -12,12 +12,6 @@ concurrency: group: publish-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - packages: write - id-token: write - attestations: write - env: IMAGE_DESCRIPTION: Fizzy is Kanban as it should be. Not as it has been. SOURCE_URL: https://github.com/${{ github.repository }} @@ -27,6 +21,11 @@ jobs: name: Build and push image (${{ matrix.arch }}) runs-on: ${{ matrix.runner }} timeout-minutes: 45 + permissions: + contents: read + packages: write + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -42,13 +41,15 @@ jobs: IMAGE_NAME: ${{ github.repository }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR - uses: docker/login-action@v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -65,7 +66,7 @@ jobs: - name: Extract Docker metadata (tags, labels) with arch suffix id: meta - uses: docker/metadata-action@v5.10.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -83,7 +84,7 @@ jobs: - name: Build and push (${{ matrix.platform }}) id: build - uses: docker/build-push-action@v6.19.2 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . file: Dockerfile @@ -101,7 +102,7 @@ jobs: - name: Attest image provenance (per-arch) if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v3.2.0 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: ${{ steps.vars.outputs.canonical }} subject-digest: ${{ steps.build.outputs.digest }} @@ -113,15 +114,18 @@ jobs: if: github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + packages: write + id-token: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - name: Set up Docker Buildx (for imagetools) - uses: docker/setup-buildx-action@v3.12.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GHCR - uses: docker/login-action@v3.7.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -138,7 +142,7 @@ jobs: - name: Compute base tags (no suffix) id: meta - uses: docker/metadata-action@v5.10.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: ${{ steps.vars.outputs.canonical }} tags: | @@ -156,9 +160,11 @@ jobs: - name: Create multi-arch manifests shell: bash + env: + TAGS: ${{ steps.meta.outputs.tags }} run: | set -eu - tags="${{ steps.meta.outputs.tags }}" + tags="$TAGS" echo "Creating manifests for tags:" printf '%s\n' "$tags" while IFS= read -r tag; do @@ -184,13 +190,15 @@ jobs: done <<< "$tags" - name: Install Cosign - uses: sigstore/cosign-installer@v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Cosign sign all tags (keyless OIDC) shell: bash + env: + TAGS: ${{ steps.meta.outputs.tags }} run: | set -eu - tags="${{ steps.meta.outputs.tags }}" + tags="$TAGS" printf '%s\n' "$tags" while IFS= read -r tag; do [ -z "$tag" ] && continue diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 777471774..e09deacfb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,15 +50,17 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} # zizmor: ignore[secrets-outside-env] steps: - name: Install system packages run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: .ruby-version bundler-cache: true diff --git a/Gemfile b/Gemfile index 17e7496c0..597899b7d 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main" gem "importmap-rails" gem "propshaft" gem "stimulus-rails" -gem "turbo-rails" +gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache" # Deployment and drivers gem "bootsnap", require: false @@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2" gem "rqrcode" gem "rouge" gem "jbuilder" -gem "lexxy", bc: "lexxy" +gem "lexxy", "0.8.5.beta" gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4e3653aeb..a015538d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,18 @@ -GIT - remote: https://github.com/basecamp/lexxy - revision: b7f0f1429df5fa425eac043191ae2b65edee7488 - specs: - lexxy (0.7.6.beta) - rails (>= 8.0.2) - GIT remote: https://github.com/basecamp/useragent revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e specs: useragent (0.16.11) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 + branch: offline-cache + specs: + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + GIT remote: https://github.com/rails/rails.git revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -125,15 +127,15 @@ GIT GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.16) + action_text-trix (2.1.17) railties addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1218.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1226.0) + aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -144,8 +146,8 @@ GEM aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.213.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.215.0) + aws-sdk-core (~> 3, >= 3.243.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -158,7 +160,7 @@ GEM bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.2) + brakeman (8.0.4) racc builder (3.3.0) bundler-audit (0.9.3) @@ -193,7 +195,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.6.0) + faker (3.6.1) i18n (>= 1.8.11, < 2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) @@ -231,7 +233,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -252,6 +254,8 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) + lexxy (0.8.5.beta) + rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.0) @@ -432,14 +436,14 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.9.0-aarch64-linux-gnu) - sqlite3 (2.9.0-aarch64-linux-musl) - sqlite3 (2.9.0-arm-linux-gnu) - sqlite3 (2.9.0-arm-linux-musl) - sqlite3 (2.9.0-arm64-darwin) - sqlite3 (2.9.0-x86_64-darwin) - sqlite3 (2.9.0-x86_64-linux-gnu) - sqlite3 (2.9.0-x86_64-linux-musl) + sqlite3 (2.9.2-aarch64-linux-gnu) + sqlite3 (2.9.2-aarch64-linux-musl) + sqlite3 (2.9.2-arm-linux-gnu) + sqlite3 (2.9.2-arm-linux-musl) + sqlite3 (2.9.2-arm64-darwin) + sqlite3 (2.9.2-x86_64-darwin) + sqlite3 (2.9.2-x86_64-linux-gnu) + sqlite3 (2.9.2-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -452,18 +456,15 @@ GEM railties (>= 6.0.0) stringio (3.2.0) thor (1.5.0) - thruster (0.1.18) - thruster (0.1.18-aarch64-linux) - thruster (0.1.18-arm64-darwin) - thruster (0.1.18-x86_64-darwin) - thruster (0.1.18-x86_64-linux) + thruster (0.1.19) + thruster (0.1.19-aarch64-linux) + thruster (0.1.19-arm64-darwin) + thruster (0.1.19-x86_64-darwin) + thruster (0.1.19-x86_64-linux) timeout (0.6.0) trilogy (2.10.0) bigdecimal tsort (0.2.0) - turbo-rails (2.0.23) - actionpack (>= 7.1.0) - railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -517,7 +518,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy! + lexxy (= 0.8.5.beta) mission_control-jobs mittens mocha @@ -539,7 +540,7 @@ DEPENDENCIES stimulus-rails thruster trilogy (~> 2.10) - turbo-rails + turbo-rails! useragent! vcr web-console! diff --git a/Gemfile.saas b/Gemfile.saas index 39aa49a71..384f5b4b7 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -5,7 +5,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing -gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 0e35effac..cf0de1423 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,13 +21,6 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/lexxy - revision: b7f0f1429df5fa425eac043191ae2b65edee7488 - specs: - lexxy (0.7.6.beta) - rails (>= 8.0.2) - GIT remote: https://github.com/basecamp/queenbee-plugin revision: 14312a940471e20617b38cdec7c092a01567d18b @@ -60,6 +53,15 @@ GIT rails (>= 6.1) yabeda (~> 0.6) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 + branch: offline-cache + specs: + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + GIT remote: https://github.com/rails/rails.git revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -202,7 +204,7 @@ GEM httpx (~> 1.6) jwt (>= 2) railties (>= 8.0) - action_text-trix (2.1.16) + action_text-trix (2.1.17) railties actionpack-xml_parser (2.0.1) actionpack (>= 5.0) @@ -222,8 +224,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1218.0) - aws-sdk-core (3.242.0) + aws-partitions (1.1226.0) + aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -234,8 +236,8 @@ GEM aws-sdk-kms (1.122.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.213.0) - aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-s3 (1.215.0) + aws-sdk-core (~> 3, >= 3.243.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -248,7 +250,7 @@ GEM bindex (0.8.1) bootsnap (1.23.0) msgpack (~> 1.2) - brakeman (8.0.2) + brakeman (8.0.4) racc builder (3.3.0) bundler-audit (0.9.3) @@ -284,7 +286,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - faker (3.6.0) + faker (3.6.1) i18n (>= 1.8.11, < 2) faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) @@ -344,7 +346,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.1) jwt (3.1.2) base64 kamal (2.10.1) @@ -365,6 +367,8 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) + lexxy (0.8.5.beta) + rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.0) @@ -603,14 +607,14 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.9.0-aarch64-linux-gnu) - sqlite3 (2.9.0-aarch64-linux-musl) - sqlite3 (2.9.0-arm-linux-gnu) - sqlite3 (2.9.0-arm-linux-musl) - sqlite3 (2.9.0-arm64-darwin) - sqlite3 (2.9.0-x86_64-darwin) - sqlite3 (2.9.0-x86_64-linux-gnu) - sqlite3 (2.9.0-x86_64-linux-musl) + sqlite3 (2.9.2-aarch64-linux-gnu) + sqlite3 (2.9.2-aarch64-linux-musl) + sqlite3 (2.9.2-arm-linux-gnu) + sqlite3 (2.9.2-arm-linux-musl) + sqlite3 (2.9.2-arm64-darwin) + sqlite3 (2.9.2-x86_64-darwin) + sqlite3 (2.9.2-x86_64-linux-gnu) + sqlite3 (2.9.2-x86_64-linux-musl) sshkit (1.25.0) base64 logger @@ -622,20 +626,16 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) - stripe (18.0.1) thor (1.5.0) - thruster (0.1.18) - thruster (0.1.18-aarch64-linux) - thruster (0.1.18-arm64-darwin) - thruster (0.1.18-x86_64-darwin) - thruster (0.1.18-x86_64-linux) + thruster (0.1.19) + thruster (0.1.19-aarch64-linux) + thruster (0.1.19-arm64-darwin) + thruster (0.1.19-x86_64-darwin) + thruster (0.1.19-x86_64-linux) timeout (0.6.0) trilogy (2.10.0) bigdecimal tsort (0.2.0) - turbo-rails (2.0.23) - actionpack (>= 7.1.0) - railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -724,7 +724,7 @@ DEPENDENCIES jbuilder kamal letter_opener - lexxy! + lexxy (= 0.8.5.beta) mission_control-jobs mittens mocha @@ -749,10 +749,9 @@ DEPENDENCIES sqlite3 (>= 2.0) stackprof stimulus-rails - stripe (~> 18.0) thruster trilogy (~> 2.10) - turbo-rails + turbo-rails! useragent! vcr web-console! diff --git a/app/assets/images/authentication.svg b/app/assets/images/authentication.svg new file mode 100644 index 000000000..6f02cc18b --- /dev/null +++ b/app/assets/images/authentication.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/1password_dark.svg b/app/assets/images/passkeys/1password_dark.svg new file mode 100644 index 000000000..ff3998898 --- /dev/null +++ b/app/assets/images/passkeys/1password_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/passkeys/1password_light.svg b/app/assets/images/passkeys/1password_light.svg new file mode 100644 index 000000000..dcd5b1ce0 --- /dev/null +++ b/app/assets/images/passkeys/1password_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/passkeys/apple_passwords_dark.svg b/app/assets/images/passkeys/apple_passwords_dark.svg new file mode 100644 index 000000000..bd1715076 --- /dev/null +++ b/app/assets/images/passkeys/apple_passwords_dark.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/apple_passwords_light.svg b/app/assets/images/passkeys/apple_passwords_light.svg new file mode 100644 index 000000000..bd1715076 --- /dev/null +++ b/app/assets/images/passkeys/apple_passwords_light.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/bitwarden_dark.svg b/app/assets/images/passkeys/bitwarden_dark.svg new file mode 100644 index 000000000..761c97ab7 --- /dev/null +++ b/app/assets/images/passkeys/bitwarden_dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/passkeys/bitwarden_light.svg b/app/assets/images/passkeys/bitwarden_light.svg new file mode 100644 index 000000000..761c97ab7 --- /dev/null +++ b/app/assets/images/passkeys/bitwarden_light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/assets/images/passkeys/dashlane_dark.svg b/app/assets/images/passkeys/dashlane_dark.svg new file mode 100644 index 000000000..f23ec9e3f --- /dev/null +++ b/app/assets/images/passkeys/dashlane_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/passkeys/dashlane_light.svg b/app/assets/images/passkeys/dashlane_light.svg new file mode 100644 index 000000000..49f658ffd --- /dev/null +++ b/app/assets/images/passkeys/dashlane_light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/assets/images/passkeys/generic_dark.svg b/app/assets/images/passkeys/generic_dark.svg new file mode 100644 index 000000000..e80a38065 --- /dev/null +++ b/app/assets/images/passkeys/generic_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/generic_light.svg b/app/assets/images/passkeys/generic_light.svg new file mode 100644 index 000000000..3f941f839 --- /dev/null +++ b/app/assets/images/passkeys/generic_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/google_password_manager_dark.svg b/app/assets/images/passkeys/google_password_manager_dark.svg new file mode 100644 index 000000000..b13ae8a5e --- /dev/null +++ b/app/assets/images/passkeys/google_password_manager_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/google_password_manager_light.svg b/app/assets/images/passkeys/google_password_manager_light.svg new file mode 100644 index 000000000..b13ae8a5e --- /dev/null +++ b/app/assets/images/passkeys/google_password_manager_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/lastpass_dark.svg b/app/assets/images/passkeys/lastpass_dark.svg new file mode 100644 index 000000000..764d273f5 --- /dev/null +++ b/app/assets/images/passkeys/lastpass_dark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/lastpass_light.svg b/app/assets/images/passkeys/lastpass_light.svg new file mode 100644 index 000000000..aac8009ea --- /dev/null +++ b/app/assets/images/passkeys/lastpass_light.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/assets/images/passkeys/samsung_pass_dark.svg b/app/assets/images/passkeys/samsung_pass_dark.svg new file mode 100644 index 000000000..399033941 --- /dev/null +++ b/app/assets/images/passkeys/samsung_pass_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/samsung_pass_light.svg b/app/assets/images/passkeys/samsung_pass_light.svg new file mode 100644 index 000000000..399033941 --- /dev/null +++ b/app/assets/images/passkeys/samsung_pass_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/windows_hello_dark.svg b/app/assets/images/passkeys/windows_hello_dark.svg new file mode 100644 index 000000000..960a7af56 --- /dev/null +++ b/app/assets/images/passkeys/windows_hello_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/windows_hello_light.svg b/app/assets/images/passkeys/windows_hello_light.svg new file mode 100644 index 000000000..960a7af56 --- /dev/null +++ b/app/assets/images/passkeys/windows_hello_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/yubikey_dark.svg b/app/assets/images/passkeys/yubikey_dark.svg new file mode 100644 index 000000000..61bf17f54 --- /dev/null +++ b/app/assets/images/passkeys/yubikey_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/passkeys/yubikey_light.svg b/app/assets/images/passkeys/yubikey_light.svg new file mode 100644 index 000000000..61bf17f54 --- /dev/null +++ b/app/assets/images/passkeys/yubikey_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/access-tokens.css b/app/assets/stylesheets/access-tokens.css index a0f54f91c..1fcecb52e 100644 --- a/app/assets/stylesheets/access-tokens.css +++ b/app/assets/stylesheets/access-tokens.css @@ -1,19 +1,33 @@ -.access_tokens_table { +.access-tokens { border-collapse: collapse; + font-size: var(--text-small); inline-size: 100%; + margin-block-end: 2lh; td, th { - border-block-end: 1px solid var(--color-ink-light); - padding-inline: var(--inline-space); + border-block-end: 1px solid var(--color-ink-lighter); text-align: start; + + &:first-child { + inline-size: 100%; + } + + &:not(:first-child) { + padding-inline-start: var(--inline-space); + } + + &:not(:last-child) { + padding-inline-end: var(--inline-space); + } } th { + color: var(--color-ink-dark); font-size: var(--text-x-small); text-transform: uppercase; } - tr:nth-of-type(even) { - background-color: var(--color-ink-lightest); + td { + padding-block: 8px; } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css index 85dc7652d..8e3808ec5 100644 --- a/app/assets/stylesheets/attachments.css +++ b/app/assets/stylesheets/attachments.css @@ -3,8 +3,8 @@ block-size: auto; display: block; inline-size: fit-content; - position: relative; max-inline-size: 100%; + position: relative; progress { inline-size: 100%; @@ -35,8 +35,7 @@ @supports (field-sizing: content) { field-sizing: content; - inline-size: auto; - min-inline-size: 20ch; + inline-size: 100%; } } } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3dc16d5a2..407b1beac 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -34,16 +34,6 @@ } } - @media (max-width: 639px) { - &:has(.card__closed) { - padding-block-start: 4.5rem; - - .card-perma__bg { - box-shadow: 0 -2rem 0 0 var(--color-container); - } - } - } - @media (max-width: 799px) { --half-btn-height: 1.25rem; --padding-inline: 1.5ch; @@ -156,37 +146,74 @@ @media (max-width: 639px) { --meta-spacer-block: 0.75ch; - inline-size: 100%; - grid-template-areas: - "avatars-author text-added" - "avatars-author text-author" - "text-updated text-updated" - "text-assignees text-assignees" - "avatars-assignees avatars-assignees"; - grid-template-columns: auto 1fr; + min-inline-size: 0; + gap: calc(var(--meta-spacer-block) / 2); + display: flex; + flex-wrap: wrap; .card__meta-text { border: 0; padding: 0; } - .card__meta-text + .card__meta-text { + .card__meta-avatars--author { + --btn-size: 1.5em; + + display: initial; + margin-inline-end: unset; + order: 3; + } + + .card__meta-text--added { + inline-size: 100%; + order: 1; + } + + .card__meta-text--author { + order: 2; + } + + .card__meta-text--updated { border-block-start: var(--card-border); - margin-block-start: var(--meta-spacer-block); + inline-size: 100%; + margin-block-start: calc(var(--meta-spacer-block) * 0.5); + order: 4; padding-block-start: var(--meta-spacer-block); } + .card__meta-text--assignees { + margin-block-start: calc(var(--meta-spacer-block) * 3); + order: 6; + white-space: unset !important; + } + .card__meta-avatars--assignees { - margin-inline: 0; - margin-block-start: var(--meta-spacer-block); + margin-inline: 0 var(--meta-spacer-inline); + margin-block-start: calc(var(--meta-spacer-block) * 3); + order: 5; + + .avatar { + display: grid; + } } - .card__meta-avatars--author { - display: initial; - } + &:has(.card__meta-avatars--assignees .avatar) { + .card__meta-text--assignees { + order: 5; + } - .card__meta-avatars--assignees .avatar { - display: grid; + .card__meta-avatars--assignees { + margin-block-start: var(--meta-spacer-block); + order: 6; + } + } + } + } + + &:has(.card__closed) .card__meta { + @media (max-width: 639px) { + .card__meta-avatars--assignees { + display: none; } } } @@ -225,8 +252,7 @@ .card__closed { @media (max-width: 639px) { - inset: auto 0 100% auto; - translate: 0 30%; + inset: auto 0 3rem auto; scale: 75%; } } @@ -252,10 +278,16 @@ @media (max-width: 639px) { display: grid; font-size: var(--text-x-small); + gap: 1ch 0; grid-template-columns: 1fr auto; grid-template-areas: "meta bg-zoom" "meta reactions"; + + &:not(:has(.reaction)), + &:has(.card__background) { + column-gap: 2ch; + } } } @@ -511,4 +543,12 @@ } } } + + .card-perma__account-limit-message { + background-color: var(--color-canvas); + border: 2px solid var(--color-container); + border-radius: 4px; + margin-block-start: calc(var(--padding-block) / -2); + padding: 1ch 2ch; + } } diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 2369d62b3..05c51dc57 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -220,4 +220,12 @@ display: none; } } + + .comment-by-system--account-limit { + --stripe-color: oklch(var(--lch-blue-lightest)); + + .comment__content { + padding: 3ch; + } + } } diff --git a/app/assets/stylesheets/credentials.css b/app/assets/stylesheets/credentials.css new file mode 100644 index 000000000..d57583017 --- /dev/null +++ b/app/assets/stylesheets/credentials.css @@ -0,0 +1,43 @@ +@layer components { + .credential { + border-block-start: var(--border); + list-style: none; + + &:last-child { + border-block-end: var(--border); + } + } + + .credential__link { + align-items: center; + block-size: 1.75lh; + color: currentcolor; + display: flex; + gap: 1ch; + padding-inline: 1ch; + + @media (any-hover: hover) { + &:hover { + background: var(--color-ink-lightest); + + .credential__arrow { + opacity: 0.66; + } + } + } + } + + .credential__arrow { + margin-inline-start: auto; + opacity: 0; + } + + [data-passkey-errors] [data-passkey-error] { + display: none; + } + + [data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"], + [data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] { + display: block; + } +} diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 42a3a21ec..2f7fe8564 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -27,6 +27,7 @@ .icon--art { --svg: url("art.svg "); } .icon--assigned { --svg: url("assigned.svg "); } .icon--attachment { --svg: url("attachment.svg "); } + .icon--authentication { --svg: url("authentication.svg "); } .icon--bell-alert { --svg: url("bell-alert.svg "); } .icon--bell-off { --svg: url("bell-off.svg "); } .icon--bell { --svg: url("bell.svg "); } diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 8fb7658ee..053f626a9 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -120,6 +120,10 @@ margin-block: 1em; } + .lexxy-prompt-menu { + max-inline-size: min(35ch, calc(100% - var(--lexxy-prompt-offset-x))); + } + /* Content /* ------------------------------------------------------------------------ */ @@ -188,4 +192,29 @@ .attachment { margin-inline: 0; } + + .attachment-gallery { + .attachment { + display: inline-block; + inline-size: calc(33.333% - 0.8ch); + } + + &.attachment-gallery--2, + &.attachment-gallery--4 { + .attachment { + inline-size: calc(50% - 0.8ch); + } + } + } + + action-text-attachment[content-type^='application/vnd.actiontext'] { + lexxy-node-delete-button { + inset-inline-start: -0.25ch; + + .lexxy-floating-controls__group { + background-color: oklch(var(--lch-blue-dark)); + border-radius: 50%; + } + } + } } diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 591ce8890..e636eae14 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -59,16 +59,26 @@ /* ------------------------------------------------------------------------ */ .card-perma { - margin-block-start: 0; + margin-block-start: var(--block-space-half); &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { - padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + padding-block-end: clamp(0.25rem, 2vw, var(--padding-block)); + } + + .card { + background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); + box-shadow: unset; + } + + .card__board { + border-radius: 0 var(--border-radius) var(--border-radius) 0; } } .card-perma__bg { - border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); - border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); + padding-inline: 0; + padding-block-start: 0; + background-color: unset; } .card-perma__closure-message { @@ -77,21 +87,10 @@ } .card-perma--draft { - .card-perma__bg { - padding-inline: 0; - padding-block-start: 0; - background-color: unset; - } - .card { - background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color)); box-shadow: 0 101vh 0 100vh var(--card-bg-color); } - .card__board { - border-radius: 0 var(--border-radius) var(--border-radius) 0; - } - .card-perma__notch--bottom { z-index: 1; } @@ -131,3 +130,9 @@ display: none; } } + +[data-bridge-components~=stamp] { + [data-controller~=bridge--stamp] { + display: none; + } +} diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 40e29011a..89cf58692 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -31,9 +31,10 @@ .txt-capitalize-first-letter::first-letter { text-transform: capitalize; } .txt-link { color: var(--color-link); text-decoration: underline; } - .font-weight-normal { font-weight: normal; } + .font-weight-normal { font-weight: 400; } + .font-weight-medium { font-weight: 500; } .font-weight-semibold { font-weight: 600; } - .font-weight-bold { font-weight: bold; } + .font-weight-bold { font-weight: 700; } .font-weight-black { font-weight: 900; } /* Flexbox and Grid */ @@ -287,4 +288,28 @@ display: none; } } + + .hide-on-dark-mode { + html[data-theme="dark"] & { + display: none; + } + + html:not([data-theme]) & { + @media (prefers-color-scheme: dark) { + display: none; + } + } + } + + .hide-on-light-mode { + html[data-theme="light"] & { + display: none; + } + + html:not([data-theme]) & { + @media (prefers-color-scheme: light) { + display: none; + } + } + } } diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb index 9876e99d8..44c9debe5 100644 --- a/app/controllers/account/entropies_controller.rb +++ b/app/controllers/account/entropies_controller.rb @@ -1,13 +1,22 @@ class Account::EntropiesController < ApplicationController + wrap_parameters :entropy, include: [ :auto_postpone_period_in_days ] + before_action :ensure_admin def update - Current.account.entropy.update!(entropy_params) - redirect_to account_settings_path, notice: "Account updated" + @account = Current.account + @account.entropy.update!(entropy_params) + + respond_to do |format| + format.html { redirect_to account_settings_path, notice: "Account updated" } + format.json { render "account/settings/show", status: :ok } + end + rescue ActiveRecord::RecordInvalid + head :unprocessable_entity end private def entropy_params - params.expect(entropy: [ :auto_postpone_period ]) + params.expect(entropy: [ :auto_postpone_period_in_days ]) end end diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index dbe0a11c1..32fb5bd42 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -6,11 +6,20 @@ class Account::ExportsController < ApplicationController CURRENT_EXPORT_LIMIT = 10 def show + respond_to do |format| + format.html + format.json { @export ? render(:show) : head(:not_found) } + end end def create - Current.account.exports.create!(user: Current.user).build_later - redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." + @export = Current.account.exports.create!(user: Current.user) + @export.build_later + + respond_to do |format| + format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." } + format.json { render :show, status: :created } + end end private @@ -23,6 +32,7 @@ class Account::ExportsController < ApplicationController end def set_export - @export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user) + scope = request.format.json? ? Current.account.exports : Current.account.exports.completed + @export = scope.find_by(id: params[:id], user: Current.user) end end diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 217ee603c..e7306308b 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -1,4 +1,6 @@ class Account::JoinCodesController < ApplicationController + wrap_parameters :account_join_code, include: %i[ usage_limit ] + before_action :set_join_code before_action :ensure_admin, only: %i[ update destroy ] @@ -10,15 +12,25 @@ class Account::JoinCodesController < ApplicationController def update if @join_code.update(join_code_params) - redirect_to account_join_code_path + respond_to do |format| + format.html { redirect_to account_join_code_path } + format.json { head :no_content } + end else - render :edit, status: :unprocessable_entity + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @join_code.errors, status: :unprocessable_entity } + end end end def destroy @join_code.reset - redirect_to account_join_code_path + + respond_to do |format| + format.html { redirect_to account_join_code_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 27f47ab99..e4b949070 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -1,14 +1,23 @@ class Account::SettingsController < ApplicationController + wrap_parameters :account, include: %i[ name ] + before_action :ensure_admin, only: :update before_action :set_account def show - @users = @account.users.active.alphabetically.includes(:identity) + respond_to do |format| + format.html { @users = @account.users.active.alphabetically.includes(:identity) } + format.json + end end def update @account.update!(account_params) - redirect_to account_settings_path + + respond_to do |format| + format.html { redirect_to account_settings_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 4b71e7f3e..d0bd13c55 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -1,4 +1,6 @@ class Boards::ColumnsController < ApplicationController + wrap_parameters :column, include: %i[ name color ] + include BoardScoped before_action :set_column, only: %i[ show update destroy ] @@ -18,7 +20,7 @@ class Boards::ColumnsController < ApplicationController respond_to do |format| format.turbo_stream - format.json { head :created, location: board_column_path(@board, @column, format: :json) } + format.json { render :show, status: :created, location: board_column_path(@board, @column, format: :json) } end end diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb index e42631eaf..89e262545 100644 --- a/app/controllers/boards/entropies_controller.rb +++ b/app/controllers/boards/entropies_controller.rb @@ -1,14 +1,23 @@ class Boards::EntropiesController < ApplicationController + wrap_parameters :board, include: [ :auto_postpone_period_in_days ] + include BoardScoped before_action :ensure_permission_to_admin_board def update @board.update!(entropy_params) + + respond_to do |format| + format.turbo_stream + format.json { render "boards/show", status: :ok } + end + rescue ActiveRecord::RecordInvalid + head :unprocessable_entity end private def entropy_params - params.expect(board: [ :auto_postpone_period ]) + params.expect(board: [ :auto_postpone_period_in_days ]) end end diff --git a/app/controllers/boards/involvements_controller.rb b/app/controllers/boards/involvements_controller.rb index a904f2989..271b50262 100644 --- a/app/controllers/boards/involvements_controller.rb +++ b/app/controllers/boards/involvements_controller.rb @@ -3,5 +3,11 @@ class Boards::InvolvementsController < ApplicationController def update @board.access_for(Current.user).update!(involvement: params[:involvement]) + + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 604938deb..3d5485f1f 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -1,4 +1,6 @@ class BoardsController < ApplicationController + wrap_parameters :board, include: %i[ name all_access auto_postpone_period_in_days public_description ] + include FilterScoped before_action :set_board, except: %i[ index new create ] @@ -26,7 +28,7 @@ class BoardsController < ApplicationController respond_to do |format| format.html { redirect_to board_path(@board) } - format.json { head :created, location: board_path(@board, format: :json) } + format.json { render :show, status: :created, location: board_path(@board, format: :json) } end end @@ -88,7 +90,7 @@ class BoardsController < ApplicationController end def board_params - params.expect(board: [ :name, :all_access, :auto_postpone_period, :public_description ]) + params.expect(board: [ :name, :all_access, :auto_postpone_period_in_days, :public_description ]) end def grantees diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index 0c822360e..d6b202bb3 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -1,4 +1,6 @@ class Cards::Comments::ReactionsController < ApplicationController + wrap_parameters :reaction, include: %i[ content ] + include CardScoped before_action :set_comment @@ -22,7 +24,7 @@ class Cards::Comments::ReactionsController < ApplicationController respond_to do |format| format.turbo_stream { render "reactions/create" } - format.json { head :created } + format.json { render "reactions/show", status: :created } end end diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index f2b318813..7aed33c8c 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -1,4 +1,5 @@ class Cards::CommentsController < ApplicationController + wrap_parameters :comment, include: %i[ body created_at ] include CardScoped before_action :set_comment, only: %i[ show edit update destroy ] @@ -14,7 +15,7 @@ class Cards::CommentsController < ApplicationController respond_to do |format| format.turbo_stream - format.json { head :created, location: card_comment_path(@card, @comment, format: :json) } + format.json { render :show, status: :created, location: card_comment_path(@card, @comment, format: :json) } end end diff --git a/app/controllers/cards/publishes_controller.rb b/app/controllers/cards/publishes_controller.rb index a0378eec3..a544e6979 100644 --- a/app/controllers/cards/publishes_controller.rb +++ b/app/controllers/cards/publishes_controller.rb @@ -4,11 +4,17 @@ class Cards::PublishesController < ApplicationController def create @card.publish - if add_another_param? - card = @board.cards.create!(status: :drafted) - redirect_to card_draft_path(card), notice: "Card added" - else - redirect_to @card.board + respond_to do |format| + format.html do + if add_another_param? + card = @board.cards.create!(status: :drafted) + redirect_to card_draft_path(card), notice: "Card added" + else + redirect_to @card.board + end + end + + format.json { head :created } end end diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb index 4105a4121..aedb4194f 100644 --- a/app/controllers/cards/reactions_controller.rb +++ b/app/controllers/cards/reactions_controller.rb @@ -1,4 +1,6 @@ class Cards::ReactionsController < ApplicationController + wrap_parameters :reaction, include: %i[ content ] + include CardScoped before_action :set_reactable @@ -21,7 +23,7 @@ class Cards::ReactionsController < ApplicationController respond_to do |format| format.turbo_stream { render "reactions/create" } - format.json { head :created } + format.json { render "reactions/show", status: :created } end end diff --git a/app/controllers/cards/readings_controller.rb b/app/controllers/cards/readings_controller.rb index 75fdbac19..4b71272f8 100644 --- a/app/controllers/cards/readings_controller.rb +++ b/app/controllers/cards/readings_controller.rb @@ -4,11 +4,21 @@ class Cards::ReadingsController < ApplicationController def create @notification = @card.read_by(Current.user) record_board_access + + respond_to do |format| + format.turbo_stream + format.json { head :created } + end end def destroy @notification = @card.unread_by(Current.user) record_board_access + + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/steps_controller.rb b/app/controllers/cards/steps_controller.rb index 0b788fe36..4843da232 100644 --- a/app/controllers/cards/steps_controller.rb +++ b/app/controllers/cards/steps_controller.rb @@ -1,14 +1,20 @@ class Cards::StepsController < ApplicationController + wrap_parameters :step, include: %i[ content completed ] + include CardScoped before_action :set_step, only: %i[ show edit update destroy ] + def index + fresh_when etag: @card.steps + end + def create @step = @card.steps.create!(step_params) respond_to do |format| format.turbo_stream - format.json { head :created, location: card_step_path(@card, @step, format: :json) } + format.json { render :show, status: :created, location: card_step_path(@card, @step, format: :json) } end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index e007527b8..015ecda1f 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -1,4 +1,6 @@ class CardsController < ApplicationController + wrap_parameters :card, include: %i[ title description image created_at last_active_at ] + include FilterScoped before_action :set_board, only: %i[ create ] @@ -18,8 +20,8 @@ class CardsController < ApplicationController end format.json do - card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") - head :created, location: card_path(card, format: :json) + @card = @board.cards.create! card_params.merge(creator: Current.user, status: "published") + render :show, status: :created, location: card_path(@card, format: :json) end end end diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb index 7161c093f..78e0c0c80 100644 --- a/app/controllers/columns/left_positions_controller.rb +++ b/app/controllers/columns/left_positions_controller.rb @@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController def create @left_column = @column.left_column @column.move_left + + respond_to do |format| + format.turbo_stream + format.json { head :created } + end end end diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb index d43beb662..5d9cc37f9 100644 --- a/app/controllers/columns/right_positions_controller.rb +++ b/app/controllers/columns/right_positions_controller.rb @@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController def create @right_column = @column.right_column @column.move_right + + respond_to do |format| + format.turbo_stream + format.json { head :created } + end end end diff --git a/app/controllers/concerns/set_platform.rb b/app/controllers/concerns/set_platform.rb index 54e77d517..66de85f12 100644 --- a/app/controllers/concerns/set_platform.rb +++ b/app/controllers/concerns/set_platform.rb @@ -7,6 +7,6 @@ module SetPlatform private def platform - @platform ||= ApplicationPlatform.new(request.user_agent) + @platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent) end end diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 82cd93842..dc7e9826a 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -1,4 +1,8 @@ class My::AccessTokensController < ApplicationController + wrap_parameters :access_token, include: %i[ description permission ] + + skip_before_action :require_account + def index @access_tokens = my_access_tokens.order(created_at: :desc) end @@ -24,14 +28,19 @@ class My::AccessTokensController < ApplicationController format.json do render status: :created, json: \ - { token: access_token.token, description: access_token.description, permission: access_token.permission } + { id: access_token.id, token: access_token.token, description: access_token.description, + permission: access_token.permission, created_at: access_token.created_at.utc } end end end def destroy my_access_tokens.find(params[:id]).destroy! - redirect_to my_access_tokens_path + + respond_to do |format| + format.html { redirect_to my_access_tokens_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/my/passkey_challenges_controller.rb b/app/controllers/my/passkey_challenges_controller.rb new file mode 100644 index 000000000..12029ef9e --- /dev/null +++ b/app/controllers/my/passkey_challenges_controller.rb @@ -0,0 +1,7 @@ +class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController + include Authentication + include Authorization + + allow_unauthenticated_access + disallow_account_scope +end diff --git a/app/controllers/my/passkeys_controller.rb b/app/controllers/my/passkeys_controller.rb new file mode 100644 index 000000000..40ddaca24 --- /dev/null +++ b/app/controllers/my/passkeys_controller.rb @@ -0,0 +1,34 @@ +class My::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + + before_action :set_passkey, only: %i[ edit update destroy ] + + def index + @passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc) + @creation_options = passkey_creation_options(holder: Current.identity) + end + + def create + passkey = Current.identity.passkeys.register(passkey_creation_params) + + redirect_to edit_my_passkey_path(passkey, created: true) + end + + def edit + end + + def update + @passkey.update!(params.expect(passkey: [ :name ])) + redirect_to my_passkeys_path + end + + def destroy + @passkey.destroy! + redirect_to my_passkeys_path + end + + private + def set_passkey + @passkey = Current.identity.passkeys.find(params[:id]) + end +end diff --git a/app/controllers/notifications/settings_controller.rb b/app/controllers/notifications/settings_controller.rb index c4aab7f69..a7ae3d8a6 100644 --- a/app/controllers/notifications/settings_controller.rb +++ b/app/controllers/notifications/settings_controller.rb @@ -1,4 +1,6 @@ class Notifications::SettingsController < ApplicationController + wrap_parameters :user_settings, include: %i[ bundle_email_frequency ] + before_action :set_settings def show @@ -7,7 +9,11 @@ class Notifications::SettingsController < ApplicationController def update @settings.update!(settings_params) - redirect_to notifications_settings_path, notice: "Settings updated" + + respond_to do |format| + format.html { redirect_to notifications_settings_path, notice: "Settings updated" } + format.json { head :no_content } + end end private diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index b1ec5a0d2..70dfa4d4d 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -5,10 +5,22 @@ class SearchesController < ApplicationController @query = params[:q].blank? ? nil : params[:q] if card = Current.user.accessible_cards.find_by_id(@query) - @card = card + respond_to do |format| + format.html { @card = card } + format.json { set_page_and_extract_portion_from Current.user.accessible_cards.where(id: card.id) } + end else - set_page_and_extract_portion_from Current.user.search(@query) - @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) + respond_to do |format| + format.html do + set_page_and_extract_portion_from Current.user.search(@query) + @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) + end + + format.json do + set_page_and_extract_portion_from \ + Current.user.accessible_cards.mentioning(@query, user: Current.user).distinct.latest.preloaded + end + end end end end diff --git a/app/controllers/sessions/passkeys_controller.rb b/app/controllers/sessions/passkeys_controller.rb new file mode 100644 index 000000000..4dc087986 --- /dev/null +++ b/app/controllers/sessions/passkeys_controller.rb @@ -0,0 +1,33 @@ +class Sessions::PasskeysController < ApplicationController + include ActionPack::Passkey::Request + + disallow_account_scope + require_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded + + def create + if credential = ActionPack::Passkey.authenticate(passkey_request_params) + start_new_session_for credential.holder + + respond_to do |format| + format.html { redirect_to after_authentication_url } + format.json { render json: { session_token: session_token } } + end + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." } + format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized } + end + end + end + + private + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index cabcfa143..cfbb778f2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,4 +1,6 @@ class SessionsController < ApplicationController + include ActionPack::Passkey::Request + disallow_account_scope require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded @@ -6,6 +8,7 @@ class SessionsController < ApplicationController layout "public" def new + @request_options = passkey_request_options end def create diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index de3d81b63..5c2d9568c 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -1,4 +1,6 @@ class Signups::CompletionsController < ApplicationController + wrap_parameters :signup, include: %i[ full_name ] + layout "public" disallow_account_scope diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 56614158f..cf43724f1 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -1,4 +1,6 @@ class SignupsController < ApplicationController + wrap_parameters :signup, include: %i[ email_address ] + disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 55d8f06dd..544398331 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -16,7 +16,11 @@ class Users::AvatarsController < ApplicationController def destroy @user.avatar.destroy - redirect_to @user + + respond_to do |format| + format.html { redirect_to @user } + format.json { head :no_content } + end end private diff --git a/app/controllers/users/joins_controller.rb b/app/controllers/users/joins_controller.rb index 6baa888ae..402dd34a3 100644 --- a/app/controllers/users/joins_controller.rb +++ b/app/controllers/users/joins_controller.rb @@ -1,4 +1,6 @@ class Users::JoinsController < ApplicationController + wrap_parameters :user, include: %i[ name avatar ] + layout "public" def new @@ -6,7 +8,11 @@ class Users::JoinsController < ApplicationController def create Current.user.update!(user_params) - redirect_to landing_path + + respond_to do |format| + format.html { redirect_to landing_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/users/push_subscriptions_controller.rb b/app/controllers/users/push_subscriptions_controller.rb index b511a6b19..c52b89541 100644 --- a/app/controllers/users/push_subscriptions_controller.rb +++ b/app/controllers/users/push_subscriptions_controller.rb @@ -1,16 +1,27 @@ class Users::PushSubscriptionsController < ApplicationController + wrap_parameters :push_subscription, include: %i[ endpoint p256dh_key auth_key ] + before_action :set_push_subscriptions def index end def create - @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) + subscription = @push_subscriptions.create_with(user_agent: request.user_agent).create_or_find_by!(push_subscription_params) + + respond_to do |format| + format.html { head :no_content } + format.json { head :created } + end end def destroy @push_subscriptions.destroy_by(id: params[:id]) - redirect_to user_push_subscriptions_url + + respond_to do |format| + format.html { redirect_to user_push_subscriptions_url } + format.json { head :no_content } + end end private diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb index 711308d13..61c3262ed 100644 --- a/app/controllers/users/roles_controller.rb +++ b/app/controllers/users/roles_controller.rb @@ -1,10 +1,16 @@ class Users::RolesController < ApplicationController + wrap_parameters :user, include: %i[ role ] + before_action :set_user before_action :ensure_permission_to_administer_user def update @user.update!(role_params) - redirect_to account_settings_path + + respond_to do |format| + format.html { redirect_to account_settings_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7062b85e5..fdc528d3e 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,4 +1,6 @@ class UsersController < ApplicationController + wrap_parameters :user, include: %i[ name avatar ] + before_action :set_user, except: %i[ index ] before_action :ensure_permission_to_change_user, only: %i[ update destroy ] diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb index 82166043e..2991be51a 100644 --- a/app/controllers/webhooks/activations_controller.rb +++ b/app/controllers/webhooks/activations_controller.rb @@ -4,9 +4,12 @@ class Webhooks::ActivationsController < ApplicationController before_action :ensure_admin def create - webhook = @board.webhooks.find(params[:webhook_id]) - webhook.activate + @webhook = @board.webhooks.find(params[:webhook_id]) + @webhook.activate - redirect_to webhook + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render "webhooks/show", status: :created } + end end end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 7d79c4587..ab29badaa 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,4 +1,6 @@ class WebhooksController < ApplicationController + wrap_parameters :webhook, include: %i[ name url subscribed_actions ] + include BoardScoped before_action :ensure_admin @@ -16,21 +18,45 @@ class WebhooksController < ApplicationController end def create - webhook = @board.webhooks.create!(webhook_params) - redirect_to webhook + @webhook = @board.webhooks.new(webhook_params) + + if @webhook.save + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render :show, status: :created, location: board_webhook_url(@webhook.board, @webhook, format: :json) } + end + else + respond_to do |format| + format.html { render :new, status: :unprocessable_entity } + format.json { render json: @webhook.errors, status: :unprocessable_entity } + end + end end def edit end def update - @webhook.update!(webhook_params.except(:url)) - redirect_to @webhook + if @webhook.update(webhook_params.except(:url)) + respond_to do |format| + format.html { redirect_to @webhook } + format.json { render :show } + end + else + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: @webhook.errors, status: :unprocessable_entity } + end + end end def destroy @webhook.destroy! - redirect_to board_webhooks_path + + respond_to do |format| + format.html { redirect_to board_webhooks_path } + format.json { head :no_content } + end end private diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9e0a72f81..6b97da279 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,8 +10,13 @@ module ApplicationHelper tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end - def back_link_to(label, url, action, **options) - link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do + def back_link_to(label, url, action, prefer_referrer: [], **options) + data = { controller: "hotkey", action: action } + if prefer_referrer.any? + data[:turbo_navigation_target] = "referrerBackLink" + data[:turbo_navigation_allowed_referrer_paths] = prefer_referrer.join(",") + end + link_to url, class: "btn btn--back btn--circle-mobile", data: data, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 5f12b9259..814b0b0e2 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,6 +1,6 @@ module BoardsHelper - def link_back_to_board(board) - back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath" + def link_back_to_board(board, prefer_referrer: []) + back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath", prefer_referrer: end def link_to_edit_board(board) diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb index 4329dab88..7257ba481 100644 --- a/app/helpers/entropy_helper.rb +++ b/app/helpers/entropy_helper.rb @@ -1,8 +1,4 @@ module EntropyHelper - def entropy_auto_close_options - [ 3, 7, 30, 90, 365, 11 ] - end - def entropy_bubble_options_for(card) { daysBeforeReminder: card.entropy.days_before_reminder, diff --git a/app/javascript/application.js b/app/javascript/application.js index c43a45eaa..6642e1ab1 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -6,3 +6,4 @@ import "controllers" import "lexxy" import "@rails/actiontext" +import "lib/action_pack/passkey" diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js new file mode 100644 index 000000000..747f5d48d --- /dev/null +++ b/app/javascript/controllers/bridge/stamp_controller.js @@ -0,0 +1,44 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "stamp" + static values = { scopeSelector: { type: String, default: "body" } } + + connect() { + super.connect() + + if (this.element.closest(this.scopeSelectorValue)) { + this.notifyBridgeOfConnect() + this.#observeStamp() + } + } + + disconnect() { + super.disconnect() + this.notifyBridgeOfDisconnect() + this.stampObserver?.disconnect() + } + + notifyBridgeOfConnect() { + const bridgeElement = this.bridgeElement + + this.send("connect", { + title: bridgeElement.title, + description: bridgeElement.bridgeAttribute("description") + }) + } + + notifyBridgeOfDisconnect() { + this.send("disconnect") + } + + #observeStamp() { + this.stampObserver = new MutationObserver(() => { + this.notifyBridgeOfConnect() + }) + + this.stampObserver.observe(this.element, { + attributes: true + }) + } +} diff --git a/app/javascript/controllers/clear_offline_cache_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js new file mode 100644 index 000000000..7caa2e89b --- /dev/null +++ b/app/javascript/controllers/clear_offline_cache_controller.js @@ -0,0 +1,8 @@ +import { Controller } from "@hotwired/stimulus" +import { Turbo } from "@hotwired/turbo-rails" + +export default class extends Controller { + clearCache() { + Turbo.offline.clearCache() + } +} diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index 3e2e4618c..8217af5aa 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -15,7 +15,7 @@ export default class extends Controller { submit() { if (this.inputTarget.disabled) return - this.element.submit() + this.element.requestSubmit() this.inputTarget.disabled = true } } diff --git a/app/javascript/controllers/turbo_navigation_controller.js b/app/javascript/controllers/turbo_navigation_controller.js index d755342e9..169fb93be 100644 --- a/app/javascript/controllers/turbo_navigation_controller.js +++ b/app/javascript/controllers/turbo_navigation_controller.js @@ -1,8 +1,12 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { + static values = { label: String } + static targets = [ "referrerBackLink" ] + rememberLocation() { sessionStorage.setItem("referrerUrl", window.location.href) + sessionStorage.setItem("referrerLabel", this.labelValue) } backIfSamePath(event) { @@ -16,6 +20,20 @@ export default class extends Controller { Turbo.visit(this.#referrerUrl) } } + + referrerBackLinkTargetConnected(link) { + if (!this.#referrerUrl || !this.#referrerLabel) { return } + + const stripTrailingSlash = path => path.replace(/\/$/, "") + const allowedPaths = (link.dataset.turboNavigationAllowedReferrerPaths || "").split(",").map(stripTrailingSlash) + const referrerPath = stripTrailingSlash(new URL(this.#referrerUrl).pathname) + if (!allowedPaths.includes(referrerPath)) { return } + + link.href = this.#referrerUrl + const strong = link.querySelector("strong") + if (strong) { strong.textContent = `Back to ${this.#referrerLabel}` } + } + get #referrerPath() { if (!this.#referrerUrl) return null return new URL(this.#referrerUrl).pathname @@ -24,4 +42,8 @@ export default class extends Controller { get #referrerUrl() { return sessionStorage.getItem("referrerUrl") } + + get #referrerLabel() { + return sessionStorage.getItem("referrerLabel") + } } diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js index d7b0bd478..7e25d50a3 100644 --- a/app/javascript/controllers/upload_preview_controller.js +++ b/app/javascript/controllers/upload_preview_controller.js @@ -15,7 +15,7 @@ export default class extends Controller { } #showFileName() { - this.fileNameTarget.innerHTML = this.#file.name + this.fileNameTarget.innerText = this.#file.name this.fileNameTarget.removeAttribute("hidden") this.placeholderTarget.setAttribute("hidden", true) } diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js index 438cf22e1..f1f1bca4b 100644 --- a/app/javascript/initializers/bridge/bridge_element.js +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -5,12 +5,13 @@ BridgeElement.prototype.getButton = function() { title: this.title, icon: this.getIcon(), displayTitle: this.getDisplayTitle(), - displayAsPrimaryAction: this.getDisplayAsPrimaryAction() + displayAsPrimaryAction: this.getDisplayAsPrimaryAction(), + slot: this.getSlot() } } BridgeElement.prototype.getIcon = function() { - const url = this.bridgeAttribute(`icon-url`) + const url = this.bridgeAttribute("icon-url") if (url) { return { url } @@ -20,9 +21,13 @@ BridgeElement.prototype.getIcon = function() { } BridgeElement.prototype.getDisplayTitle = function() { - return !!this.bridgeAttribute(`display-title`) + return !!this.bridgeAttribute("display-title") } BridgeElement.prototype.getDisplayAsPrimaryAction = function() { - return !!this.bridgeAttribute(`display-as-primary-action`) + return !!this.bridgeAttribute("display-as-primary-action") +} + +BridgeElement.prototype.getSlot = function () { + return this.bridgeAttribute("slot") ?? "right" } diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 90ef36a26..d2c20451e 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1,2 +1,4 @@ import "initializers/current" import "initializers/bridge/bridge_element" +import "initializers/offline" +import "initializers/lexxy_markdown_paste" diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js new file mode 100644 index 000000000..cf7600306 --- /dev/null +++ b/app/javascript/initializers/lexxy_markdown_paste.js @@ -0,0 +1,3 @@ +document.addEventListener("lexxy:insert-markdown", (event) => { + event.detail.addBlockSpacing() +}) diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js new file mode 100644 index 000000000..877ffa0ef --- /dev/null +++ b/app/javascript/initializers/offline.js @@ -0,0 +1,9 @@ +import { Turbo } from "@hotwired/turbo-rails" + +if (Current.user) { + Turbo.offline.start("/service-worker.js", { + scope: "/", + native: true, + preload: /\/assets\// + }) +} diff --git a/app/javascript/lib/action_pack/passkey.js b/app/javascript/lib/action_pack/passkey.js new file mode 100644 index 000000000..115a630dc --- /dev/null +++ b/app/javascript/lib/action_pack/passkey.js @@ -0,0 +1,246 @@ +// JS companion for the ActionPack::Passkey Ruby helpers. +// +// Binds click handlers to passkey buttons and manages the WebAuthn ceremony +// lifecycle (challenge refresh, credential creation/authentication, form submission). +// +// Expected data attributes: +// [data-passkey="create"] — triggers the registration ceremony +// [data-passkey="sign_in"] — triggers the authentication ceremony +// [data-passkey-mediation="conditional"] — on a
, enables autofill-assisted sign in +// [data-passkey-errors] — container whose data-passkey-error-state is set on failure +// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state +// [data-passkey-field="..."] — hidden fields populated before form submission +// +// Custom events (all bubble): +// passkey:start — ceremony begun +// passkey:success — credential obtained, form about to submit +// passkey:error — ceremony failed; detail: { error, cancelled } +// +// Meta tags (rendered by the Ruby form helpers): +// — JSON WebAuthn creation options +// — JSON WebAuthn request options +// — endpoint to refresh the challenge nonce + +import { register, authenticate } from "lib/action_pack/webauthn" + +let listeners +let currentDocument + +document.addEventListener("DOMContentLoaded", setup) +document.addEventListener("turbo:load", setup) +document.addEventListener("turbo:before-cache", teardown) + +// Set error state on the nearest [data-passkey-errors] container. +// The app's CSS is responsible for showing/hiding children based on +// the data-passkey-error-state attribute ("error" or "cancelled"). +document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => { + const container = target.closest("[data-passkey-errors]") + + if (container) { + container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error" + } +}) + +// Bind click handlers to passkey buttons and attempt conditional mediation. +// Guards against duplicate setup. +function setup() { + if (currentDocument !== document.documentElement) { + currentDocument = document.documentElement + + listeners?.abort() + listeners = new AbortController() + + for (const button of document.querySelectorAll('[data-passkey="create"]')) { + button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal }) + } + + for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) { + button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal }) + } + + attemptConditionalMediation() + } +} + +// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers. +function teardown() { + currentDocument = null + listeners?.abort() + + for (const button of document.querySelectorAll('[data-passkey][disabled]')) { + button.disabled = false + } + + for (const container of document.querySelectorAll("[data-passkey-errors]")) { + delete container.dataset.passkeyErrorState + } +} + +// Run the WebAuthn registration ceremony: refresh the challenge, prompt the +// browser to create a credential, fill the form's hidden fields, and submit. +async function createPasskey(button) { + const form = button.closest("form") + + if (form) { + button.disabled = true + button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + try { + if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + + const creationOptions = getCreationOptions() + if (!creationOptions) throw new Error("Missing passkey creation options") + + await refreshChallenge(creationOptions) + const passkey = await register(creationOptions) + + button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillCreateForm(form, passkey) + form.submit() + } catch (error) { + button.disabled = false + + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } + } +} + +function passkeysAvailable() { + return !!window.PublicKeyCredential +} + +// Read WebAuthn creation options from the tag rendered by +// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing. +function getCreationOptions() { + return getOptions("passkey-creation-options") +} + +// Parse and return the JSON content of a tag by name. +function getOptions(name) { + const meta = document.querySelector(`meta[name="${name}"]`) + + if (meta) { + return JSON.parse(meta.content) + } +} + +// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks +// when the page has been open for a while before the user initiates the ceremony. +async function refreshChallenge(options) { + const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content + if (!url) throw new Error("Missing passkey challenge URL") + const token = document.querySelector('meta[name="csrf-token"]')?.content + + const response = await fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { + "X-CSRF-Token": token, + "Accept": "application/json" + } + }) + + if (!response.ok) throw new Error("Failed to refresh challenge") + + const { challenge } = await response.json() + options.challenge = challenge +} + +// Populate the registration form's hidden fields with the credential response. +// Clones the transports template input for each reported transport. +function fillCreateForm(form, passkey) { + form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json + form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object + + const template = form.querySelector('[data-passkey-field="transports"]') + for (const transport of passkey.transports) { + const input = template.cloneNode() + input.value = transport + template.before(input) + } + template.remove() +} + +// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the +// browser to sign with an existing credential, fill the form, and submit. +async function signInWithPasskey(button) { + const form = button.closest("form") + + if (form) { + button.disabled = true + button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + try { + if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser") + + const requestOptions = getRequestOptions() + if (!requestOptions) throw new Error("Missing passkey request options") + + await refreshChallenge(requestOptions) + const passkey = await authenticate(requestOptions) + + button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillSignInForm(form, passkey) + form.submit() + } catch (error) { + button.disabled = false + + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } + } +} + +// Read WebAuthn request options from the tag rendered by +// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing. +function getRequestOptions() { + return getOptions("passkey-request-options") +} + +// Populate the authentication form's hidden fields with the assertion response. +function fillSignInForm(form, passkey) { + form.querySelector('[data-passkey-field="id"]').value = passkey.id + form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json + form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data + form.querySelector('[data-passkey-field="signature"]').value = passkey.signature +} + +// Start the conditional mediation (autofill) ceremony if the page opts in with +// a form[data-passkey-mediation="conditional"] and the browser supports it. +// Unlike the button-driven ceremonies, this runs automatically on page load. +async function attemptConditionalMediation() { + if (await conditionalMediationAvailable()) { + const form = document.querySelector('form[data-passkey-mediation="conditional"]') + form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true })) + + const requestOptions = getRequestOptions() + + try { + await refreshChallenge(requestOptions) + + const passkey = await authenticate(requestOptions, { mediation: "conditional" }) + + form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true })) + fillSignInForm(form, passkey) + form.submit() + } catch (error) { + const cancelled = error.name === "AbortError" || error.name === "NotAllowedError" + form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } })) + } + } +} + +// Check all preconditions for conditional mediation: the page has opted in, +// request options are present, the browser supports passkeys, and the browser +// supports the conditional mediation UI (autofill). +async function conditionalMediationAvailable() { + return isConditionalMediationFormPresent() && + getRequestOptions() && + passkeysAvailable() && + await window.PublicKeyCredential.isConditionalMediationAvailable?.() +} + +function isConditionalMediationFormPresent() { + return !!document.querySelector('form[data-passkey-mediation="conditional"]') +} diff --git a/app/javascript/lib/action_pack/webauthn.js b/app/javascript/lib/action_pack/webauthn.js new file mode 100644 index 000000000..5d9ec9c23 --- /dev/null +++ b/app/javascript/lib/action_pack/webauthn.js @@ -0,0 +1,83 @@ +// Thin wrapper around the browser WebAuthn API (navigator.credentials). +// +// Handles the base64url ↔ ArrayBuffer conversions required by the spec so +// callers can work with plain JSON objects from the server-rendered meta tags. + +// Call navigator.credentials.create() with the given creation options. +// Returns { client_data_json, attestation_object, transports } with all +// binary fields encoded as base64url strings ready for form submission. +export async function register(options) { + const publicKey = prepareCreationOptions(options) + const credential = await navigator.credentials.create({ publicKey }) + + return { + client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), + attestation_object: bufferToBase64url(credential.response.attestationObject), + transports: credential.response.getTransports?.() || [] + } +} + +// Call navigator.credentials.get() with the given request options. +// Accepts an optional signal (AbortSignal) and mediation hint ("conditional" +// for autofill UI). Returns { id, client_data_json, authenticator_data, signature } +// with binary fields encoded as base64url strings. +export async function authenticate(options, { signal, mediation } = {}) { + const publicKey = prepareRequestOptions(options) + const credential = await navigator.credentials.get({ publicKey, signal, mediation }) + + return { + id: credential.id, + client_data_json: new TextDecoder().decode(credential.response.clientDataJSON), + authenticator_data: bufferToBase64url(credential.response.authenticatorData), + signature: bufferToBase64url(credential.response.signature) + } +} + +// Convert JSON creation options into the format expected by the browser: +// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers. +function prepareCreationOptions(options) { + return { + ...options, + challenge: base64urlToBuffer(options.challenge), + user: { ...options.user, id: base64urlToBuffer(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })) + } +} + +// Convert JSON request options into the format expected by the browser: +// decode base64url challenge and allowCredentials[].id into ArrayBuffers. +// Strips allowCredentials entirely when empty so the browser prompts for +// any available credential (required for conditional mediation). +function prepareRequestOptions(options) { + const prepared = { + ...options, + challenge: base64urlToBuffer(options.challenge) + } + + if (options.allowCredentials?.length) { + prepared.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })) + } else { + delete prepared.allowCredentials + } + + return prepared +} + +function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") + const padding = "=".repeat((4 - base64.length % 4) % 4) + const binary = atob(base64 + padding) + return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer +} + +function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer) + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} diff --git a/app/models/account/data_transfer/active_storage/attachment_record_set.rb b/app/models/account/data_transfer/active_storage/attachment_record_set.rb new file mode 100644 index 000000000..0c87c10af --- /dev/null +++ b/app/models/account/data_transfer/active_storage/attachment_record_set.rb @@ -0,0 +1,11 @@ +class Account::DataTransfer::ActiveStorage::AttachmentRecordSet < Account::DataTransfer::RecordSet + def initialize(account) + super(account: account, model: ::ActiveStorage::Attachment) + end + + private + def records + ::ActiveStorage::Attachment.where(account: account) + .where.not(record_type: INTERNAL_RECORD_TYPES) + end +end diff --git a/app/models/account/data_transfer/active_storage/blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb index 1637768f3..7adc5742e 100644 --- a/app/models/account/data_transfer/active_storage/blob_record_set.rb +++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb @@ -8,11 +8,20 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe end private + def records + ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids) + end + + def excluded_blob_ids + ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id) + end + def import_batch(files) batch_data = files.map do |file| data = load(file) data.slice(*attributes).merge( "account_id" => account.id, + "key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH), "service_name" => ::ActiveStorage::Blob.service.name ) end diff --git a/app/models/account/data_transfer/active_storage/file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb index 15d359353..f64f53d3a 100644 --- a/app/models/account/data_transfer/active_storage/file_record_set.rb +++ b/app/models/account/data_transfer/active_storage/file_record_set.rb @@ -5,7 +5,11 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe private def records - ::ActiveStorage::Blob.where(account: account) + ::ActiveStorage::Blob.where(account: account).where.not(id: excluded_blob_ids) + end + + def excluded_blob_ids + ::ActiveStorage::Attachment.where(account: account, record_type: INTERNAL_RECORD_TYPES).select(:blob_id) end def export_record(blob) @@ -22,9 +26,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe def import_batch(files) files.each do |file| - key = File.basename(file) - blob = ::ActiveStorage::Blob.find_by(key: key, account: account) - next unless blob + old_key = file.delete_prefix("storage/") + blob_id = old_key_to_blob_id[old_key] + raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id + + blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account) + raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob zip.read(file) do |stream| blob.upload(stream) @@ -32,12 +39,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe end end - def check_record(file_path) - key = File.basename(file_path) + def old_key_to_blob_id + @old_key_to_blob_id ||= build_old_key_to_blob_id + end - unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account) - # File exists without corresponding blob record - could be orphaned or blob not yet imported - # We allow this since blob metadata is imported before files + def build_old_key_to_blob_id + zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map| + data = load(file) + old_key = data["key"] + if map.key?(old_key) + raise IntegrityError, "Duplicate blob key in export: #{old_key}" + end + map[old_key] = data["id"] + end + end + + def with_zip(zip) + @old_key_to_blob_id = nil + super + end + + def check_record(file_path) + old_key = file_path.delete_prefix("storage/") + + unless old_key_to_blob_id.key?(old_key) + raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" end end end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index 2159926d5..f7a7791e1 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest [ Account::DataTransfer::AccountRecordSet.new(account), Account::DataTransfer::UserRecordSet.new(account), - *build_record_sets(::User::Settings, ::Tag, ::Board, ::Column), + *build_record_sets( + ::User::Settings, + ::Tag, + ::Board, + ::Column + ), Account::DataTransfer::EntropyRecordSet.new(account), *build_record_sets( - ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step, - ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow, - ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention, - ::Filter, ::Webhook::DelinquencyTracker, ::Event, - ::Notification, ::Notification::Bundle, ::Webhook::Delivery + ::Board::Publication, + ::Webhook, + ::Access, + ::Card, + ::Comment, + ::Step, + ::Assignment, + ::Tagging, + ::Closure, + ::Card::Goldness, + ::Card::NotNow, + ::Card::ActivitySpike, + ::Watch, + ::Pin, + ::Reaction, + ::Mention, + ::Filter, + ::Webhook::DelinquencyTracker, + ::Event, + ::Notification, + ::Notification::Bundle, + ::Webhook::Delivery ), Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account), - *build_record_sets(::ActiveStorage::Attachment), + Account::DataTransfer::ActiveStorage::AttachmentRecordSet.new(account), Account::DataTransfer::ActionText::RichTextRecordSet.new(account), Account::DataTransfer::ActiveStorage::FileRecordSet.new(account) - ] + ].then { set_importable_model_names(it) } end def build_record_sets(*models) @@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest Account::DataTransfer::RecordSet.new(account: account, model: model) end end + + def set_importable_model_names(record_sets) + model_names = record_sets.filter_map { |record_set| record_set.model&.name } + record_sets.each { |record_set| record_set.importable_model_names = model_names } + record_sets + end end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index 8eeafbe10..ff7b79b5c 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -3,13 +3,16 @@ class Account::DataTransfer::RecordSet class ConflictError < IntegrityError; end IMPORT_BATCH_SIZE = 100 + INTERNAL_RECORD_TYPES = %w[Export Account::Import].freeze + attr_accessor :importable_model_names attr_reader :account, :model, :attributes - def initialize(account:, model:, attributes: nil) + def initialize(account:, model:, attributes: nil, importable_model_names: nil) @account = account @model = model @attributes = (attributes || model.column_names).map(&:to_s) + @importable_model_names = importable_model_names || [ model.name ] end def export(to:, start: nil) @@ -113,7 +116,7 @@ class Account::DataTransfer::RecordSet def check_association_doesnt_exist(data, association, associated_id) if association.polymorphic? type_column = association.foreign_type.to_s - associated_class = data[type_column].constantize + associated_class = verify_model_type(data[type_column]) else associated_class = association.klass end @@ -123,6 +126,14 @@ class Account::DataTransfer::RecordSet end end + def verify_model_type(type_name) + if importable_model_names.include?(type_name) + type_name.constantize + else + raise IntegrityError, "Unrecognized model type: #{type_name}" + end + end + def skip_to(file_list, last_id) index = file_list.index(last_id) diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index ff97538be..7c892eebe 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -33,6 +33,12 @@ class Account::Seeder HTML + # TODO: Replace the video here with a screencap of creating a passkey + playground.cards.create! creator: creator, title: "Then, set up a Passkey", status: "published", description: <<~HTML +

Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “My Profile > Manage Passkeys”. Using a passkey is optional, but recommended.

+ + HTML + playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML

Open the Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.

diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 9b2695cd5..6494e37c5 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -43,6 +43,17 @@ class ApplicationPlatform < PlatformAgent operating_system == "Windows" end + def bridge_name + case + when native? && android? then :android + when native? && ios? then :ios + end + end + + def bridge_components + extract_list_from_native_user_agent("bridge-components") + end + def type if native? && android? "native android" @@ -67,4 +78,13 @@ class ApplicationPlatform < PlatformAgent os =~ /Linux/ ? "Linux" : os end end + + private + def extract_list_from_native_user_agent(prefix) + if native? + user_agent.to_s.match(/#{Regexp.escape(prefix)}: \[(.*?)\]/) { |matches| matches[1] }.to_s + else + "" + end + end end diff --git a/app/models/board/auto_postponing.rb b/app/models/board/auto_postponing.rb index 4911c5e33..da53692b3 100644 --- a/app/models/board/auto_postponing.rb +++ b/app/models/board/auto_postponing.rb @@ -6,9 +6,7 @@ module Board::AutoPostponing end private - DEFAULT_AUTO_POSTPONE_PERIOD = 30.days - def set_default_auto_postpone_period - self.auto_postpone_period ||= DEFAULT_AUTO_POSTPONE_PERIOD unless attribute_present?(:auto_postpone_period) + self.auto_postpone_period ||= Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS.days unless attribute_present?(:auto_postpone_period) end end diff --git a/app/models/board/entropic.rb b/app/models/board/entropic.rb index beee4883b..d3e48d6ea 100644 --- a/app/models/board/entropic.rb +++ b/app/models/board/entropic.rb @@ -2,7 +2,7 @@ module Board::Entropic extend ActiveSupport::Concern included do - delegate :auto_postpone_period, to: :entropy + delegate :auto_postpone_period, :auto_postpone_period_in_days, to: :entropy has_one :entropy, as: :container, dependent: :destroy end @@ -12,6 +12,10 @@ module Board::Entropic def auto_postpone_period=(new_value) entropy ||= association(:entropy).reader || self.build_entropy - entropy.update auto_postpone_period: new_value + entropy.update! auto_postpone_period: new_value + end + + def auto_postpone_period_in_days=(value) + self.auto_postpone_period = value.to_i.days.to_i end end diff --git a/app/models/board/publication.rb b/app/models/board/publication.rb index bd480eecc..20dcc40da 100644 --- a/app/models/board/publication.rb +++ b/app/models/board/publication.rb @@ -1,6 +1,6 @@ class Board::Publication < ApplicationRecord belongs_to :account, default: -> { board.account } - belongs_to :board + belongs_to :board, touch: true has_secure_token :key end diff --git a/app/models/entropy.rb b/app/models/entropy.rb index 9a93ba4e8..c896fc494 100644 --- a/app/models/entropy.rb +++ b/app/models/entropy.rb @@ -1,6 +1,35 @@ class Entropy < ApplicationRecord + DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS = 30 + AUTO_POSTPONE_PERIODS_IN_DAYS = [ 3, 7, 30, 90, 365, 11 ].freeze + AUTO_POSTPONE_PERIODS_IN_SECONDS = AUTO_POSTPONE_PERIODS_IN_DAYS.map { |n| n.day.in_seconds }.freeze + belongs_to :account, default: -> { container.account } belongs_to :container, polymorphic: true + validates :auto_postpone_period, inclusion: { in: AUTO_POSTPONE_PERIODS_IN_SECONDS } + after_commit -> { container.cards.touch_all if container } + + def auto_postpone_period_in_days + days = auto_postpone_period / 1.day.to_i + + if days.in?(AUTO_POSTPONE_PERIODS_IN_DAYS) + days + else + default_auto_postpone_period_in_days + end + end + + def auto_postpone_period_in_days=(new_value) + self.auto_postpone_period = new_value.to_i.days.to_i + end + + private + def default_auto_postpone_period_in_days + if container.is_a?(Board) + container.account.entropy.auto_postpone_period_in_days + else + DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS + end + end end diff --git a/app/models/identity.rb b/app/models/identity.rb index 18f4e3163..ae4f7a8cb 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,6 +1,8 @@ class Identity < ApplicationRecord include Joinable, Transferable + has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address } + has_many :access_tokens, dependent: :destroy has_many :magic_links, dependent: :destroy has_many :sessions, dependent: :destroy diff --git a/app/models/passkey/authenticator.rb b/app/models/passkey/authenticator.rb new file mode 100644 index 000000000..2af6edc58 --- /dev/null +++ b/app/models/passkey/authenticator.rb @@ -0,0 +1,23 @@ +class Passkey::Authenticator < Data.define(:aaguids, :name, :icon) + class << self + def find_by_aaguid(aaguid) + registry[aaguid] + end + + def registry + @registry ||= Hash.new.tap do |registry| + all.each do |authenticator| + authenticator.aaguids.each do |aaguid| + registry[aaguid] = authenticator + end + end + end + end + + def all + Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs| + new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon]) + end + end + end +end diff --git a/app/models/search/record/sqlite.rb b/app/models/search/record/sqlite.rb index ae0d34281..857e858a7 100644 --- a/app/models/search/record/sqlite.rb +++ b/app/models/search/record/sqlite.rb @@ -10,7 +10,10 @@ module Search::Record::SQLite after_save :upsert_to_fts5_table - scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) } + scope :matching, ->(query, account_id) { + joins("INNER JOIN search_records_fts ON search_records_fts.rowid = #{table_name}.id") + .where("search_records_fts MATCH ?", query) + } end class_methods do diff --git a/app/models/user/named.rb b/app/models/user/named.rb index d052a79ad..804e516ee 100644 --- a/app/models/user/named.rb +++ b/app/models/user/named.rb @@ -20,6 +20,6 @@ module User::Named def familiar_name names = name.split return name if names.length <= 1 - "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" + "#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}" end end diff --git a/app/models/zip_file.rb b/app/models/zip_file.rb index dc5b7ee8d..3415f5879 100644 --- a/app/models/zip_file.rb +++ b/app/models/zip_file.rb @@ -84,7 +84,8 @@ class ZipFile def read_from_s3(blob) url = blob.url(expires_in: 6.hour) - remote_io = RemoteIO.new(url) + ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer + remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer) reader = Reader.new(remote_io) yield reader end diff --git a/app/models/zip_file/remote_io.rb b/app/models/zip_file/remote_io.rb index 1cb00831a..59329d811 100644 --- a/app/models/zip_file/remote_io.rb +++ b/app/models/zip_file/remote_io.rb @@ -1,4 +1,9 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO + def initialize(url, ssl_verify_peer: true) + super(url) + @ssl_verify_peer = ssl_verify_peer + end + protected def request_range(range) with_http do |http| @@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO def with_http http = Net::HTTP.new(@uri.hostname, @uri.port) http.use_ssl = @uri.scheme == "https" - # FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer http.start { yield http } end end diff --git a/app/views/account/exports/show.json.jbuilder b/app/views/account/exports/show.json.jbuilder new file mode 100644 index 000000000..2bd9e3d60 --- /dev/null +++ b/app/views/account/exports/show.json.jbuilder @@ -0,0 +1,6 @@ +json.(@export, :id, :status) +json.created_at @export.created_at.utc + +if @export.completed? && @export.file.attached? + json.download_url rails_blob_url(@export.file, disposition: "attachment") +end diff --git a/app/views/account/join_codes/show.json.jbuilder b/app/views/account/join_codes/show.json.jbuilder new file mode 100644 index 000000000..559e7b6c2 --- /dev/null +++ b/app/views/account/join_codes/show.json.jbuilder @@ -0,0 +1,3 @@ +json.(@join_code, :code, :usage_count, :usage_limit) +json.url join_url(code: @join_code.code, script_name: Current.account.slug) +json.active !!@join_code.active? diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 91bb85aff..fdb118e57 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -21,5 +21,3 @@ <%= render "account/settings/cancellation" %> - -<%= render "account/settings/subscription_panel" if Fizzy.saas? %> diff --git a/app/views/account/settings/show.json.jbuilder b/app/views/account/settings/show.json.jbuilder new file mode 100644 index 000000000..55b623f9d --- /dev/null +++ b/app/views/account/settings/show.json.jbuilder @@ -0,0 +1,3 @@ +json.(@account, :id, :name, :cards_count) +json.created_at @account.created_at.utc +json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder index 7c85a1dc5..0eeff1896 100644 --- a/app/views/boards/_board.json.jbuilder +++ b/app/views/boards/_board.json.jbuilder @@ -1,6 +1,7 @@ json.cache! board do json.(board, :id, :name, :all_access) json.created_at board.created_at.utc + json.auto_postpone_period_in_days board.auto_postpone_period_in_days json.url board_url(board) json.creator board.creator, partial: "users/user", as: :user diff --git a/app/views/boards/columns/closeds/show.json.jbuilder b/app/views/boards/columns/closeds/show.json.jbuilder new file mode 100644 index 000000000..c1dc1dff1 --- /dev/null +++ b/app/views/boards/columns/closeds/show.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/card", as: :card diff --git a/app/views/boards/columns/not_nows/show.json.jbuilder b/app/views/boards/columns/not_nows/show.json.jbuilder new file mode 100644 index 000000000..c1dc1dff1 --- /dev/null +++ b/app/views/boards/columns/not_nows/show.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/card", as: :card diff --git a/app/views/boards/columns/streams/show.json.jbuilder b/app/views/boards/columns/streams/show.json.jbuilder new file mode 100644 index 000000000..c1dc1dff1 --- /dev/null +++ b/app/views/boards/columns/streams/show.json.jbuilder @@ -0,0 +1 @@ +json.array! @page.records, partial: "cards/card", as: :card diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb index b43309100..f66fb8fa6 100644 --- a/app/views/boards/show/_stream.html.erb +++ b/app/views/boards/show/_stream.html.erb @@ -12,7 +12,7 @@
<% if page.used? %> - <%= with_automatic_pagination "maybe", @page do %> + <%= with_automatic_pagination "maybe", page do %> <%= render "cards/display/previews", cards: page.records, draggable: true %> <% end %> <% end %> diff --git a/app/views/cards/_messages.html.erb b/app/views/cards/_messages.html.erb index a2784e72a..c51ab6d8b 100644 --- a/app/views/cards/_messages.html.erb +++ b/app/views/cards/_messages.html.erb @@ -1,7 +1,11 @@ <%= messages_tag(card) do %> <% if card.published? %> <%= render partial: "cards/comments/comment", collection: card.comments.preloaded.chronologically, cached: true %> - <%= render "cards/comments/new", card: card %> + <% if Fizzy.saas? %> + <%= render "cards/comments/saas/new", card: card %> + <% else %> + <%= render "cards/comments/new", card: card %> + <% end %> <%= render "cards/comments/watchers", card: card %> <% end %> diff --git a/app/views/cards/closures/create.turbo_stream.erb b/app/views/cards/closures/create.turbo_stream.erb index c4898e471..5b76efe7b 100644 --- a/app/views/cards/closures/create.turbo_stream.erb +++ b/app/views/cards/closures/create.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/closures/destroy.turbo_stream.erb b/app/views/cards/closures/destroy.turbo_stream.erb index 8df408979..31a9e41c3 100644 --- a/app/views/cards/closures/destroy.turbo_stream.erb +++ b/app/views/cards/closures/destroy.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @card.column %> <%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %> <% elsif @card.awaiting_triage? %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 27db6590e..99fcad627 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,13 +1,27 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %> + data: { + controller: "hotkey", + action: "keydown.e@document->hotkey#click", + bridge__overflow_menu_target: "item", + bridge_title: "Edit", + turbo_frame: dom_id(card, :edit) + } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e <% end %> - <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" }, + <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless hide-on-native", + data: { + controller: "hotkey", + form_target: "submit", + bridge__buttons_target: ("button" unless card.postponed?), + bridge_title: "Mark done", + bridge_display_as_primary_action: true, + bridge_display_title: true, bridge_icon_url: bridge_icon("check"), + action: "keydown.d@document->hotkey#click" + }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb index b55cae344..4e5a83e20 100644 --- a/app/views/cards/container/footer/_create.html.erb +++ b/app/views/cards/container/footer/_create.html.erb @@ -14,6 +14,6 @@ <% end %>
- <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %> + <%= render "cards/container/footer/saas/storage_limit_notice" if Fizzy.saas? %>
diff --git a/app/views/cards/display/common/_assignees.html.erb b/app/views/cards/display/common/_assignees.html.erb index 73bddd901..75ebe4611 100644 --- a/app/views/cards/display/common/_assignees.html.erb +++ b/app/views/cards/display/common/_assignees.html.erb @@ -12,8 +12,8 @@ - <% if Current.user && !local_assigns[:preview] %> - <%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %> + <% unless local_assigns[:preview] %> + <%= button_to "Assign to me", card_self_assignment_path(card), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %> <% end %> diff --git a/app/views/cards/display/common/_stamp.html.erb b/app/views/cards/display/common/_stamp.html.erb index 629e4cf24..86190e0f6 100644 --- a/app/views/cards/display/common/_stamp.html.erb +++ b/app/views/cards/display/common/_stamp.html.erb @@ -1,15 +1,25 @@ <% if card.postponed? %> -
"> + <%= tag.div class: token_list("card__closed", "card__closed--system": card.postponed_by&.system?), data: { + controller: "bridge--stamp", + bridge__stamp_scope_selector_value: ".card-perma", + bridge_title: "Not Now", + bridge_description: card.postponed_at.strftime("%b %d, %Y") + } do %> Not Now <%= card.postponed_at.strftime("%b %d, %Y") %> -
+ <% end %> <% end %> <% if card.closed? %> -
+ <%= tag.div class: "card__closed", data: { + controller: "bridge--stamp", + bridge__stamp_scope_selector_value: ".card-perma", + bridge_title: "Done", + bridge_description: card.closed_at.strftime("%b %d, %Y") + } do %> Done <%= card.closed_at.strftime("%b %d, %Y") %> -
+ <% end %> <% end %> diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb index 9128580e2..945d86ab2 100644 --- a/app/views/cards/not_nows/create.turbo_stream.erb +++ b/app/views/cards/not_nows/create.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/show.html.erb b/app/views/cards/show.html.erb index fabd2ad02..9acb305bf 100644 --- a/app/views/cards/show.html.erb +++ b/app/views/cards/show.html.erb @@ -7,7 +7,7 @@ <% content_for :header do %>
- <%= link_back_to_board(@card.board) %> + <%= link_back_to_board @card.board, prefer_referrer: [ root_path, cards_path, board_path(@card.board) ] %>
<% end %> diff --git a/app/views/cards/steps/index.json.jbuilder b/app/views/cards/steps/index.json.jbuilder new file mode 100644 index 000000000..087790175 --- /dev/null +++ b/app/views/cards/steps/index.json.jbuilder @@ -0,0 +1 @@ +json.array! @card.steps, partial: "cards/steps/step", as: :step diff --git a/app/views/entropy/_auto_close.html.erb b/app/views/entropy/_auto_close.html.erb index 6ccb5d205..a2e3fe0ab 100644 --- a/app/views/entropy/_auto_close.html.erb +++ b/app/views/entropy/_auto_close.html.erb @@ -6,8 +6,9 @@ <%= form_with model: model, url: url, data: { controller: "form" } do |form| %> <%= render "entropy/knob", form: form, - name: :auto_postpone_period, - knob_options: entropy_auto_close_options, + name: :auto_postpone_period_in_days, + current_value: model.auto_postpone_period_in_days, + knob_options: Entropy::AUTO_POSTPONE_PERIODS_IN_DAYS, label: "Days until auto-close", disabled: disabled %> <% end %> diff --git a/app/views/entropy/_knob.html.erb b/app/views/entropy/_knob.html.erb index 69bbb5fb0..ba17c6728 100644 --- a/app/views/entropy/_knob.html.erb +++ b/app/views/entropy/_knob.html.erb @@ -1,6 +1,5 @@ <% - period = form.object.send(name) - current_index = knob_options.index(period.to_i / 1.day) + current_index = knob_options.index(current_value) || knob_options.index(Entropy::DEFAULT_AUTO_POSTPONE_PERIOD_IN_DAYS) %>
@@ -8,7 +7,7 @@ <% knob_options.each_with_index do |value, index| %>