diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000000..dba71e970e --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2d3..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/Gemfile.lock b/Gemfile.lock index 504bffd01f..ea92feb395 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -449,7 +449,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 0b0e79cb84..6dcb7b9ee3 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -568,7 +568,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 073508bff6..e68706765a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -25,7 +25,7 @@ position: relative; &:has(dialog[open]) { - z-index: 2; + z-index: 3; } &:has(.card-perma__star-input:checked) { @@ -281,10 +281,7 @@ /* Overlap the card BG by half the button height */ &:has(.btn) { - &, - ~ .card-perma__closure-message { - translate: 0 calc(-1 * var(--half-btn-height)); - } + translate: 0 calc(-1 * var(--half-btn-height)); } form { @@ -297,12 +294,6 @@ --btn-color: var(--color-ink-inverted); } - .btn--plain { - --btn-color: var(--card-color); - - text-decoration: underline; - } - .btn--reversed { --btn-background: var(--color-canvas); --btn-color: var(--card-color); @@ -330,11 +321,25 @@ .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; - margin-block-start: 0.5ch; + margin-block: var(--block-space) var(--block-space-double); padding-inline: 1ch; - form { - display: inline; + .btn--plain { + --btn-color: var(--card-color); + + text-decoration: underline; + } + + @media (max-width: 799px) { + margin-block: var(--block-space-half); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } + + @media (min-width: 800px) { + .card-perma__notch--bottom:has(.btn) ~ & { + margin-block: var(--block-space-half) var(--block-space); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } } } } diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 5d6a7eb0ef..1336cad9b7 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -1,4 +1,9 @@ @layer components { + /* Prevent page scrolling when modal dialog is open */ + html:has(dialog:modal) { + overflow: hidden; + } + :is(.dialog) { border: 0; opacity: 0; @@ -35,4 +40,9 @@ } } } + + /* Ensure padding from viewport edges */ + .dialog.panel { + max-inline-size: calc(100vw - var(--inline-space-double) * 2); + } } diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 96df0e00ba..8226a52612 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -6,6 +6,8 @@ --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + --footer-height: 0; + -webkit-tap-highlight-color: transparent; .hide-on-native { @@ -28,11 +30,27 @@ } } + /* Card columns + /* ------------------------------------------------------------------------ */ + + .board-tools.card { + padding-block-start: 0; + } + /* Card perma /* ------------------------------------------------------------------------ */ .card-perma { margin-block-start: 0; + + &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { + padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + } + } + + .card-perma__closure-message { + margin-block: var(--block-space); + translate: unset; } /* Search @@ -43,3 +61,25 @@ } } } + +[data-bridge-components~=form] { + [data-controller~=bridge--form] { + [data-bridge--form-target~=submit] { + display: none; + } + } +} + +[data-bridge-components~=overflow-menu] { + [data-controller~=bridge--overflow-menu] { + [data-bridge--overflow-menu-target~=item] { + display: none; + } + } +} + +[data-bridge-components~=buttons] { + [data-bridge--buttons-target~=button] { + display: none; + } +} diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index e27494153b..aa0b8c1daf 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -53,6 +53,10 @@ .popup__title { font-weight: 800; white-space: nowrap; + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } /* Hide lists when all the items within are hidden */ diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb index 9876e99d8e..2891e30711 100644 --- a/app/controllers/account/entropies_controller.rb +++ b/app/controllers/account/entropies_controller.rb @@ -3,7 +3,10 @@ class Account::EntropiesController < ApplicationController def update Current.account.entropy.update!(entropy_params) - redirect_to account_settings_path, notice: "Account updated" + respond_to do |format| + format.html { redirect_to account_settings_path, notice: "Account updated" } + format.json { head :no_content } + end end private diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index ab40286af7..19665ff699 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -5,11 +5,25 @@ class Account::ExportsController < ApplicationController CURRENT_EXPORT_LIMIT = 10 def show + respond_to do |format| + format.html + format.json do + if @export + render json: @export.as_json(only: %i[id status created_at]) + else + head :not_found + end + end + 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 json: export.as_json(only: %i[id status created_at]), status: :accepted } + end end private diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 217ee603ca..5326108d3d 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -3,6 +3,10 @@ class Account::JoinCodesController < ApplicationController before_action :ensure_admin, only: %i[ update destroy ] def show + respond_to do |format| + format.html + format.json { render json: @join_code.as_json(only: %i[code usage_limit usage_count]) } + end end def edit @@ -10,15 +14,24 @@ def edit 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: { errors: @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 { render json: @join_code.as_json(only: %i[code usage_limit usage_count]) } + end end private diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 27f47ab995..6afab754b7 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -4,11 +4,18 @@ class Account::SettingsController < ApplicationController def show @users = @account.users.active.alphabetically.includes(:identity) + respond_to do |format| + format.html + format.json { render json: @account.as_json(only: %i[id name]) } + 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/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb index e42631eaf6..eb6374a472 100644 --- a/app/controllers/boards/entropies_controller.rb +++ b/app/controllers/boards/entropies_controller.rb @@ -5,6 +5,11 @@ class Boards::EntropiesController < ApplicationController def update @board.update!(entropy_params) + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/boards/involvements_controller.rb b/app/controllers/boards/involvements_controller.rb index a904f2989d..d9898b400b 100644 --- a/app/controllers/boards/involvements_controller.rb +++ b/app/controllers/boards/involvements_controller.rb @@ -3,5 +3,10 @@ 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/publications_controller.rb b/app/controllers/boards/publications_controller.rb index 9435bd8bf4..eb969bf28c 100644 --- a/app/controllers/boards/publications_controller.rb +++ b/app/controllers/boards/publications_controller.rb @@ -5,10 +5,20 @@ class Boards::PublicationsController < ApplicationController def create @board.publish + respond_to do |format| + format.html + format.turbo_stream + format.json { render json: { key: @board.publication.key, url: published_board_url(@board) } } + end end def destroy @board.unpublish @board.reload + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb index f8da07da31..b4e2548e4e 100644 --- a/app/controllers/cards/pins_controller.rb +++ b/app/controllers/cards/pins_controller.rb @@ -8,15 +8,25 @@ def show def create @pin = @card.pin_by Current.user - broadcast_add_pin_to_tray - render_pin_button_replacement + respond_to do |format| + format.html do + broadcast_add_pin_to_tray + render_pin_button_replacement + end + format.json { head :no_content } + end end def destroy @pin = @card.unpin_by Current.user - broadcast_remove_pin_from_tray - render_pin_button_replacement + respond_to do |format| + format.html do + broadcast_remove_pin_from_tray + render_pin_button_replacement + end + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/publishes_controller.rb b/app/controllers/cards/publishes_controller.rb index a0378eec32..729b27c6ad 100644 --- a/app/controllers/cards/publishes_controller.rb +++ b/app/controllers/cards/publishes_controller.rb @@ -4,11 +4,16 @@ 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 :no_content } end end diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb index 7161c093fb..7a27c381ba 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.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb index d43beb6628..2248d1c741 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.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb index 99c87893f7..5d68a33f03 100644 --- a/app/controllers/my/access_tokens_controller.rb +++ b/app/controllers/my/access_tokens_controller.rb @@ -27,7 +27,7 @@ def destroy private def my_access_tokens - Current.identity.access_tokens + Current.identity.access_tokens.personal end def access_token_params diff --git a/app/controllers/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb new file mode 100644 index 0000000000..322451db7e --- /dev/null +++ b/app/controllers/my/connected_apps_controller.rb @@ -0,0 +1,28 @@ +class My::ConnectedAppsController < ApplicationController + before_action :set_connected_apps, only: :index + before_action :set_oauth_client, only: :destroy + + def index + end + + def destroy + @tokens.destroy_all + + redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected" + end + + private + def set_connected_apps + tokens = oauth_tokens.includes(:oauth_client).order(:created_at) + @connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase } + end + + def set_oauth_client + @tokens = oauth_tokens.where(oauth_client_id: params.require(:id)) + @client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound + end + + def oauth_tokens + Current.identity.access_tokens.oauth + end +end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb new file mode 100644 index 0000000000..3ddc00b43a --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,125 @@ +class Oauth::AuthorizationsController < Oauth::BaseController + # Allow form submission to the client's redirect_uri for OAuth callbacks + # Only widen CSP if the client allows this redirect_uri (validated in before_action) + content_security_policy only: :new do |policy| + if (redirect_uri = params[:redirect_uri]).present? && (client_id = params[:client_id]).present? + client = Oauth::Client.find_by(client_id: client_id) + if client&.allows_redirect?(redirect_uri) + begin + uri = URI.parse(redirect_uri) + origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port}" + policy.form_action :self, origin + rescue URI::InvalidURIError + # Invalid URI - don't widen CSP + end + end + end + end + + before_action :save_oauth_return_url + before_action :require_authentication + + before_action :set_client + before_action :validate_redirect_uri + before_action :validate_response_type + before_action :validate_pkce + before_action :validate_scope + before_action :validate_state + + def new + @scope = params[:scope].presence || "read" + @redirect_uri = params[:redirect_uri] + @state = params[:state] + @code_challenge = params[:code_challenge] + end + + def create + if params[:error] == "access_denied" + redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true + else + code = Oauth::AuthorizationCode.generate \ + client_id: @client.client_id, + identity_id: Current.identity.id, + code_challenge: params[:code_challenge], + redirect_uri: params[:redirect_uri], + scope: params[:scope].presence || "read" + + redirect_to success_redirect_uri(code), allow_other_host: true + end + end + + private + def save_oauth_return_url + session[:return_to_after_authenticating] = request.url if request.get? && !authenticated? + end + + def set_client + @client = Oauth::Client.find_by(client_id: params[:client_id]) + oauth_error("invalid_request", "Unknown client") unless @client + end + + def validate_redirect_uri + unless performed? || @client.allows_redirect?(params[:redirect_uri]) + redirect_with_error "invalid_request", "Invalid redirect_uri" + end + end + + def validate_response_type + unless performed? || params[:response_type] == "code" + redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported" + end + end + + def validate_pkce + unless performed? || params[:code_challenge].present? + redirect_with_error "invalid_request", "code_challenge is required" + end + + unless performed? || params[:code_challenge_method] == "S256" + redirect_with_error "invalid_request", "code_challenge_method must be S256" + end + end + + def validate_scope + unless performed? || @client.allows_scope?(params[:scope].presence || "read") + redirect_with_error "invalid_scope", "Requested scope is not allowed" + end + end + + def validate_state + unless performed? || params[:state].present? + redirect_with_error "invalid_request", "state is required" + end + end + + def redirect_with_error(error, description) + if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri]) + redirect_to error_redirect_uri(error, description), allow_other_host: true + else + @error = error + @error_description = description + render :error, status: :bad_request + end + end + + def success_redirect_uri(code) + build_redirect_uri params[:redirect_uri], + code: code, + state: params[:state].presence + end + + def error_redirect_uri(error, description) + build_redirect_uri params[:redirect_uri], + error: error, + error_description: description, + state: params[:state].presence + end + + def build_redirect_uri(base, **query_params) + uri = URI.parse(base) + query = URI.decode_www_form(uri.query || "") + query_params.compact.each { |k, v| query << [ k.to_s, v ] } + uri.query = URI.encode_www_form(query) + uri.to_s + end +end diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb new file mode 100644 index 0000000000..658d1e2d48 --- /dev/null +++ b/app/controllers/oauth/base_controller.rb @@ -0,0 +1,12 @@ +class Oauth::BaseController < ApplicationController + disallow_account_scope + + private + def oauth_error(error, description = nil, status: :bad_request) + render json: { error: error, error_description: description }.compact, status: status + end + + def oauth_rate_limit_exceeded + oauth_error "slow_down", "Too many requests", status: :too_many_requests + end +end diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb new file mode 100644 index 0000000000..df0c13f1a6 --- /dev/null +++ b/app/controllers/oauth/clients_controller.rb @@ -0,0 +1,75 @@ +class Oauth::ClientsController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_redirect_uris + before_action :validate_loopback_uris + before_action :validate_auth_method + + def create + client = Oauth::Client.create! \ + name: params[:client_name] || "MCP Client", + redirect_uris: Array(params[:redirect_uris]), + scopes: validated_scopes, + dynamically_registered: true + + render json: dynamic_client_registration_response(client), status: :created + rescue ActiveRecord::RecordInvalid => e + oauth_error "invalid_client_metadata", e.message + end + + private + def validate_redirect_uris + unless performed? || params[:redirect_uris].present? + oauth_error "invalid_client_metadata", "redirect_uris is required" + end + end + + def validate_loopback_uris + unless performed? || all_loopback_uris?(params[:redirect_uris]) + oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration" + end + end + + def validate_auth_method + unless performed? || params[:token_endpoint_auth_method].blank? || params[:token_endpoint_auth_method] == "none" + oauth_error "invalid_client_metadata", "Only 'none' token_endpoint_auth_method is supported" + end + end + + def all_loopback_uris?(uris) + uris.is_a?(Array) && + uris.all? { |uri| uri.is_a?(String) && valid_loopback_uri?(uri) } + end + + def valid_loopback_uri?(uri) + parsed = URI.parse(uri) + parsed.scheme == "http" && + Oauth::LOOPBACK_HOSTS.include?(parsed.host) && + parsed.fragment.nil? + rescue URI::InvalidURIError + false + end + + def validated_scopes + requested = case params[:scope] + when String then params[:scope].split + when Array then params[:scope].select { |s| s.is_a?(String) } + else [] + end + requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ] + end + + def dynamic_client_registration_response(client) + { + client_id: client.client_id, + client_name: client.name, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: "none", + grant_types: %w[ authorization_code ], + response_types: %w[ code ], + scope: client.scopes.join(" ") + } + end +end diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb new file mode 100644 index 0000000000..9bc84ea15e --- /dev/null +++ b/app/controllers/oauth/metadata_controller.rb @@ -0,0 +1,18 @@ +class Oauth::MetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + issuer: root_url(script_name: nil), + authorization_endpoint: new_oauth_authorization_url, + token_endpoint: oauth_token_url, + registration_endpoint: oauth_clients_url, + revocation_endpoint: oauth_revocation_url, + response_types_supported: %w[ code ], + grant_types_supported: %w[ authorization_code ], + token_endpoint_auth_methods_supported: %w[ none ], + code_challenge_methods_supported: %w[ S256 ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb new file mode 100644 index 0000000000..d7f6d4895d --- /dev/null +++ b/app/controllers/oauth/protected_resource_metadata_controller.rb @@ -0,0 +1,12 @@ +class Oauth::ProtectedResourceMetadataController < Oauth::BaseController + allow_unauthenticated_access + + def show + render json: { + resource: root_url(script_name: nil), + authorization_servers: [ root_url(script_name: nil) ], + bearer_methods_supported: %w[ header ], + scopes_supported: %w[ read write ] + } + end +end diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb new file mode 100644 index 0000000000..6dca9777d3 --- /dev/null +++ b/app/controllers/oauth/revocations_controller.rb @@ -0,0 +1,16 @@ +class Oauth::RevocationsController < Oauth::BaseController + allow_unauthenticated_access + + before_action :set_access_token + + def create + @access_token&.destroy + + head :ok # Don't behave as oracle, per RFC 7009 + end + + private + def set_access_token + @access_token = Identity::AccessToken.find_by(token: params.require(:token)) + end +end diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb new file mode 100644 index 0000000000..11cfefce0b --- /dev/null +++ b/app/controllers/oauth/tokens_controller.rb @@ -0,0 +1,61 @@ +class Oauth::TokensController < Oauth::BaseController + allow_unauthenticated_access + + rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded + + before_action :validate_grant_type + before_action :set_auth_code + before_action :set_client + before_action :validate_pkce + before_action :validate_redirect_uri + before_action :set_identity + + def create + granted = @auth_code.scope.to_s.split + permission = granted.include?("write") ? "write" : "read" + access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission + + render json: { + access_token: access_token.token, + token_type: "Bearer", + scope: granted.join(" ") + } + end + + private + def validate_grant_type + unless params[:grant_type] == "authorization_code" + oauth_error "unsupported_grant_type", "Only authorization_code grant is supported" + end + end + + def set_auth_code + unless @auth_code = Oauth::AuthorizationCode.parse(params[:code]) + oauth_error "invalid_grant", "Invalid or expired authorization code" + end + end + + def set_client + unless @client = Oauth::Client.find_by(client_id: @auth_code.client_id) + oauth_error "invalid_grant", "Unknown client" + end + end + + def validate_pkce + unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier]) + oauth_error "invalid_grant", "PKCE verification failed" + end + end + + def validate_redirect_uri + unless @auth_code.redirect_uri == params[:redirect_uri] + oauth_error "invalid_grant", "redirect_uri mismatch" + end + end + + def set_identity + unless @identity = Identity.find_by(id: @auth_code.identity_id) + oauth_error "invalid_grant", "Identity not found" + end + end +end diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb index 711308d13a..ec4057eb47 100644 --- a/app/controllers/users/roles_controller.rb +++ b/app/controllers/users/roles_controller.rb @@ -4,7 +4,10 @@ class Users::RolesController < ApplicationController 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/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 7d79c45873..71f58450e0 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -16,8 +16,11 @@ def new end def create - webhook = @board.webhooks.create!(webhook_params) - redirect_to webhook + @webhook = @board.webhooks.create!(webhook_params) + respond_to do |format| + format.html { redirect_to @webhook } + format.json + end end def edit @@ -25,12 +28,18 @@ def edit def update @webhook.update!(webhook_params.except(:url)) - redirect_to @webhook + respond_to do |format| + format.html { redirect_to @webhook } + format.json { head :no_content } + 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/accesses_helper.rb b/app/helpers/accesses_helper.rb index 1ace03574d..c5a4ae1c62 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -48,7 +48,8 @@ def involvement_button(board, access, show_watchers, icon_only) params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, aria: { labelledby: dom_id(board, :involvement_label) }, title: (label_text if icon_only), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), + data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index a1960b3410..9b9ee89b36 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,8 @@ def link_back_to_board(board) end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn", data: { controller: "tooltip" } do + link_to edit_board_path(board), class: "btn", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb new file mode 100644 index 0000000000..5fb4508b69 --- /dev/null +++ b/app/helpers/bridge_helper.rb @@ -0,0 +1,5 @@ +module BridgeHelper + def bridge_icon(name) + asset_url("#{name}.svg") + end +end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 6c3f01725e..a0529b11a1 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -40,6 +40,10 @@ def filter_dialog(label, &block) }, &block end + def filter_title(title) + tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" } + end + def collapsible_nav_section(title, **properties, &block) tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 095655e3b6..2902f809d8 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -9,4 +9,23 @@ def auto_submit_form_with(**attributes, &) form_with(**attributes, data: data) { } end end + + def bridged_form_with(**attributes, &) + data = attributes.delete(:data) || {} + controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip + actions = [ + data[:action], + "turbo:submit-start->bridge--form#submitStart", + "turbo:submit-end->bridge--form#submitEnd" + ].compact.join(" ").strip + + data[:controller] = controllers + data[:action] = actions + + if block_given? + form_with **attributes, data: data, & + else + form_with(**attributes, data: data) { } + end + end end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index f3bb077c83..a2e14af29a 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -25,7 +25,7 @@ def webhook_action_label(action) def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), class: [ "btn", { "btn--reversed": board.webhooks.any? } ], - data: { controller: "tooltip" } do + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end end diff --git a/app/javascript/application.js b/app/javascript/application.js index bdafb95b09..c43a45eaa6 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,8 +1,8 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" +import "@hotwired/hotwire-native-bridge" import "initializers" import "controllers" import "lexxy" import "@rails/actiontext" - diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js new file mode 100644 index 0000000000..47d09a158f --- /dev/null +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "buttons" + static targets = [ "button" ] + + buttonTargetConnected() { + this.notifyBridgeOfConnect() + } + + buttonTargetDisconnected() { + if (!this.#isControllerTearingDown()) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const buttons = this.#enabledButtonTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { ...element.getButton(), index } + }) + + this.send("connect", { buttons }, message => { + this.#clickButton(message) + }) + } + + #clickButton(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledButtonTargets[selectedIndex].click() + } + + get #enabledButtonTargets() { + return this.buttonTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js new file mode 100644 index 0000000000..a3c780e62a --- /dev/null +++ b/app/javascript/controllers/bridge/form_controller.js @@ -0,0 +1,62 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "form" + static targets = [ "submit", "cancel" ] + static values = { submitTitle: String } + + submitTargetConnected() { + this.notifyBridgeOfConnect() + this.#observeSubmitTarget() + } + + submitTargetDisconnected() { + this.notifyBridgeOfDisonnect() + this.submitObserver?.disconnect() + } + + notifyBridgeOfConnect() { + const submitElement = new BridgeElement(this.submitTarget) + const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null + + const submitButton = { title: submitElement.title } + const cancelButton = cancelElement ? { title: cancelElement.title } : null + + this.send("connect", { submitButton, cancelButton }, message => this.receive(message)) + } + + receive(message) { + switch (message.event) { + case "submit": + this.submitTarget.click() + break + case "cancel": + this.cancelTarget.click() + break + } + } + + notifyBridgeOfDisonnect() { + this.send("disconnect") + } + + submitStart() { + this.send("submitStart") + } + + submitEnd() { + this.send("submitEnd") + } + + #observeSubmitTarget() { + this.submitObserver = new MutationObserver(() => { + this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") + }) + + this.submitObserver.observe(this.submitTarget, { + attributes: true, + attributeFilter: [ "disabled" ] + }) + } +} diff --git a/app/javascript/controllers/bridge/insets_controller.js b/app/javascript/controllers/bridge/insets_controller.js new file mode 100644 index 0000000000..1cef156906 --- /dev/null +++ b/app/javascript/controllers/bridge/insets_controller.js @@ -0,0 +1,31 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +// Bridge component to control custom safe-area insets from native apps. +// Sets CSS variables --injected-safe-inset-(top|right|bottom|left). +export default class extends BridgeComponent { + static component = "insets" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setInsets(message.data) + }) + } + + #setInsets({ top, right, bottom, left }) { + const root = document.documentElement.style + root.setProperty("--injected-safe-inset-top", `${top}px`) + root.setProperty("--injected-safe-inset-right", `${right}px`) + root.setProperty("--injected-safe-inset-bottom", `${bottom}px`) + root.setProperty("--injected-safe-inset-left", `${left}px`) + } +} diff --git a/app/javascript/controllers/bridge/overflow_menu_controller.js b/app/javascript/controllers/bridge/overflow_menu_controller.js new file mode 100644 index 0000000000..ba2045dc61 --- /dev/null +++ b/app/javascript/controllers/bridge/overflow_menu_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "overflow-menu" + static targets = [ "item" ] + + itemTargetConnected() { + this.notifyBridgeOfConnect() + } + + itemTargetDisconnected() { + if (!this.#isControllerTearingDown) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const items = this.#enabledItemTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { title: element.title, index } + }) + + this.send("connect", { items }, message => { + this.#clickItem(message) + }) + } + + #clickItem(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledItemTargets[selectedIndex].click() + } + + get #enabledItemTargets() { + return this.itemTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/text_size_controller.js b/app/javascript/controllers/bridge/text_size_controller.js new file mode 100644 index 0000000000..a464b8d45b --- /dev/null +++ b/app/javascript/controllers/bridge/text_size_controller.js @@ -0,0 +1,25 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "text-size" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setTextSize(message.data) + }) + } + + #setTextSize(data) { + document.documentElement.dataset.textSize = data.textSize + } +} diff --git a/app/javascript/controllers/bridge/title_controller.js b/app/javascript/controllers/bridge/title_controller.js new file mode 100644 index 0000000000..f2226be350 --- /dev/null +++ b/app/javascript/controllers/bridge/title_controller.js @@ -0,0 +1,63 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { viewport } from "helpers/bridge/viewport_helpers" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends BridgeComponent { + static component = "title" + static targets = [ "header" ] + static values = { title: String } + + async connect() { + super.connect() + await nextFrame() + this.#startObserver() + window.addEventListener("resize", this.#windowResized) + } + + disconnect() { + super.disconnect() + this.#stopObserver() + window.removeEventListener("resize", this.#windowResized) + } + + notifyBridgeOfVisibilityChange(visible) { + this.send("visibility", { title: this.#title, elementVisible: visible }) + } + + // Intersection Observer + + #startObserver() { + if (!this.hasHeaderTarget) return + + this.observer = new IntersectionObserver(([ entry ]) => + this.notifyBridgeOfVisibilityChange(entry.isIntersecting), + { rootMargin: `-${this.#topOffset}px 0px 0px 0px` } + ) + + this.observer.observe(this.headerTarget) + this.previousTopOffset = this.#topOffset + } + + #stopObserver() { + this.observer?.disconnect() + } + + #updateObserverIfNeeded() { + if (this.#topOffset === this.previousTopOffset) return + + this.#stopObserver() + this.#startObserver() + } + + #windowResized = () => { + this.#updateObserverIfNeeded() + } + + get #title() { + return this.titleValue ? this.titleValue : document.title + } + + get #topOffset() { + return viewport.top + } +} diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index f6449cea7b..037c5ec7b5 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -1,8 +1,9 @@ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" +import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { - static targets = [ "dialog" ] + static targets = [ "dialog", "focusMouse", "focusTouch" ] static values = { modal: { type: Boolean, default: false }, sizing: { type: Boolean, default: true }, @@ -14,6 +15,10 @@ export default class extends Controller { if (this.autoOpenValue) this.open() } + focusTouchTargetConnected() { + this.#setupFocus() + } + open() { const modal = this.modalValue @@ -63,4 +68,10 @@ export default class extends Controller { captureKey(event) { if (event.key !== "Escape") { event.stopPropagation() } } + + #setupFocus() { + const touch = isTouchDevice() + if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch + if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch + } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index beca1f08f3..2ba58ab89c 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -54,6 +54,10 @@ export default class extends Controller { } submitToTopTarget(event) { + const value = event.target.value?.trim() + + if (!value) return false + this.element.setAttribute("data-turbo-frame", "_top") this.submit() } diff --git a/app/javascript/helpers/bridge/viewport_helpers.js b/app/javascript/helpers/bridge/viewport_helpers.js new file mode 100644 index 0000000000..559143607e --- /dev/null +++ b/app/javascript/helpers/bridge/viewport_helpers.js @@ -0,0 +1,24 @@ +let top = 0 +const viewportTarget = window.visualViewport || window + +export const viewport = { + get top() { + return top + }, + get height() { + return viewportTarget.height || window.innerHeight + } +} + +function update() { + requestAnimationFrame(() => { + const styles = getComputedStyle(document.documentElement) + const customInset = styles.getPropertyValue("--custom-safe-inset-top") + const fallbackInset = styles.getPropertyValue("--safe-area-inset-top") + const insetValue = (customInset || fallbackInset).trim() + top = parseInt(insetValue || "0", 10) || 0 + }) +} + +viewportTarget.addEventListener("resize", update) +update() diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js new file mode 100644 index 0000000000..7ddeb1b335 --- /dev/null +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -0,0 +1,18 @@ +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +BridgeElement.prototype.getButton = function() { + return { + title: this.title, + icon: this.getIcon() + } +} + +BridgeElement.prototype.getIcon = function() { + const url = this.bridgeAttribute(`icon-url`) + + if (url) { + return { url } + } + + return null +} diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 10fb369755..90ef36a26a 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1 +1,2 @@ import "initializers/current" +import "initializers/bridge/bridge_element" diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 8b39504ef0..17d1f186a2 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -1,6 +1,8 @@ module Searchable extend ActiveSupport::Concern + SEARCH_CONTENT_LIMIT = 32.kilobytes + included do after_create_commit :create_in_search_index after_update_commit :update_in_search_index @@ -32,11 +34,15 @@ def search_record_attributes card_id: search_card_id, board_id: search_board_id, title: search_title, - content: search_content, + content: search_record_content, created_at: created_at } end + def search_record_content + search_content&.truncate_bytes(SEARCH_CONTENT_LIMIT, omission: "") + end + def search_record_class Search::Record.for(account_id) end diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb index abdf37eba8..6cd1fc3b24 100644 --- a/app/models/identity/access_token.rb +++ b/app/models/identity/access_token.rb @@ -1,5 +1,9 @@ class Identity::AccessToken < ApplicationRecord belongs_to :identity + belongs_to :oauth_client, class_name: "Oauth::Client", optional: true + + scope :personal, -> { where oauth_client_id: nil } + scope :oauth, -> { where.not oauth_client_id: nil } has_secure_token enum :permission, %w[ read write ].index_by(&:itself), default: :read diff --git a/app/models/oauth.rb b/app/models/oauth.rb new file mode 100644 index 0000000000..5770882e85 --- /dev/null +++ b/app/models/oauth.rb @@ -0,0 +1,7 @@ +module Oauth + LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ] + + def self.table_name_prefix + "oauth_" + end +end diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb new file mode 100644 index 0000000000..32460b57f1 --- /dev/null +++ b/app/models/oauth/authorization_code.rb @@ -0,0 +1,38 @@ +module Oauth::AuthorizationCode + Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope) + + class << self + def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:) + payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope: } + encryptor.encrypt_and_sign(payload, expires_in: 60.seconds) + end + + def parse(code) + if code.present? && data = encryptor.decrypt_and_verify(code) + Details.new \ + client_id: data["client_id"], + identity_id: data["identity_id"], + code_challenge: data["code_challenge"], + redirect_uri: data["redirect_uri"], + scope: data["scope"] + end + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def valid_pkce?(code_data, code_verifier) + code_data && code_verifier.present? && + ActiveSupport::SecurityUtils.secure_compare(pkce_challenge(code_verifier), code_data.code_challenge) + end + + private + def encryptor + @encryptor ||= ActiveSupport::MessageEncryptor.new \ + Rails.application.key_generator.generate_key("oauth/authorization_codes", 32) + end + + def pkce_challenge(verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + end + end +end diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb new file mode 100644 index 0000000000..dd29156169 --- /dev/null +++ b/app/models/oauth/client.rb @@ -0,0 +1,74 @@ +class Oauth::Client < ApplicationRecord + has_many :access_tokens, class_name: "Identity::AccessToken" + + has_secure_token :client_id, length: 32 + + validates :name, presence: true + validates :client_id, uniqueness: true, allow_nil: true + validates :redirect_uris, presence: true + validate :redirect_uris_are_valid + + attribute :redirect_uris, default: -> { [] } + attribute :scopes, default: -> { %w[ read ] } + + scope :trusted, -> { where trusted: true } + scope :dynamically_registered, -> { where dynamically_registered: true } + + + def loopback? + redirect_uris.all? { |uri| loopback_uri?(uri) } + end + + def allows_redirect?(uri) + redirect_uris.include?(uri) || (loopback? && loopback_uri?(uri) && matching_loopback?(uri)) + end + + def allows_scope?(requested_scope) + requested = requested_scope.to_s.split + requested.present? && requested.all? { |s| scopes.include?(s) } + end + + private + def redirect_uris_are_valid + redirect_uris.each { |uri| validate_redirect_uri(uri) } + end + + def validate_redirect_uri(uri) + parsed = URI.parse(uri) + + if parsed.fragment.present? + errors.add :redirect_uris, "must not contain fragments" + end + + if dynamically_registered? && !valid_loopback_uri?(parsed) + errors.add :redirect_uris, "must be a local loopback URI for dynamically registered clients" + end + rescue URI::InvalidURIError + errors.add :redirect_uris, "includes an invalid URI" + end + + def loopback_uri?(uri) + Oauth::LOOPBACK_HOSTS.include?(URI.parse(uri).host) + rescue URI::InvalidURIError + false + end + + def valid_loopback_uri?(parsed) + parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS) + end + + def matching_loopback?(uri) + parsed = URI.parse(uri) + + redirect_uris.any? do |redirect_uri| + redirect = URI.parse(redirect_uri) + + redirect.scheme == parsed.scheme && + redirect.host.in?(Oauth::LOOPBACK_HOSTS) && + parsed.host.in?(Oauth::LOOPBACK_HOSTS) && + redirect.path == parsed.path + end + rescue URI::InvalidURIError + false + end +end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 74e2744148..4385606357 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Account Settings" %> <% content_for :header do %> -