diff --git a/app/views/doorkeeper/applications/_delete_form.html.erb b/app/views/doorkeeper/applications/_delete_form.html.erb
new file mode 100644
index 00000000..654fb2a6
--- /dev/null
+++ b/app/views/doorkeeper/applications/_delete_form.html.erb
@@ -0,0 +1,6 @@
+<%- submit_btn_css ||= 'btn btn-link' %>
+<%= form_tag oauth_application_path(application), method: :delete do %>
+ <%= submit_tag t('doorkeeper.applications.buttons.destroy'),
+ onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')",
+ class: submit_btn_css %>
+<% end %>
diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb
new file mode 100644
index 00000000..de86503b
--- /dev/null
+++ b/app/views/doorkeeper/applications/_form.html.erb
@@ -0,0 +1,59 @@
+<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: 'form' } do |f| %>
+ <% if application.errors.any? %>
+
<%= t('doorkeeper.applications.form.error') %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+<% end %>
diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb
new file mode 100644
index 00000000..737186bc
--- /dev/null
+++ b/app/views/doorkeeper/applications/edit.html.erb
@@ -0,0 +1,5 @@
+
+
<%= t('.title') %>
+
+
+<%= render 'form', application: @application %>
diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb
new file mode 100644
index 00000000..3ba2650d
--- /dev/null
+++ b/app/views/doorkeeper/applications/index.html.erb
@@ -0,0 +1,38 @@
+
+
<%= t('.title') %>
+
+
+<%= link_to t('.new'), new_oauth_application_path, class: 'btn btn-success' %>
+
+
+
+
+ <%= t('.name') %> |
+ <%= t('.callback_url') %> |
+ <%= t('.confidential') %> |
+ <%= t('.actions') %> |
+ |
+
+
+
+ <% @applications.each do |application| %>
+
+
+ <%= link_to application.name, oauth_application_path(application) %>
+ |
+
+ <%= simple_format(application.redirect_uri) %>
+ |
+
+ <%= application.confidential? ? t('doorkeeper.applications.index.confidentiality.yes') : t('doorkeeper.applications.index.confidentiality.no') %>
+ |
+
+ <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %>
+ |
+
+ <%= render 'delete_form', application: application %>
+ |
+
+ <% end %>
+
+
diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb
new file mode 100644
index 00000000..737186bc
--- /dev/null
+++ b/app/views/doorkeeper/applications/new.html.erb
@@ -0,0 +1,5 @@
+
+
<%= t('.title') %>
+
+
+<%= render 'form', application: @application %>
diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb
new file mode 100644
index 00000000..540ba484
--- /dev/null
+++ b/app/views/doorkeeper/applications/show.html.erb
@@ -0,0 +1,63 @@
+
+
<%= t('.title', name: @application.name) %>
+
+
+
+
+
<%= t('.application_id') %>:
+
<%= @application.uid %>
+
+
<%= t('.secret') %>:
+
+
+ <% secret = flash[:application_secret].presence || @application.plaintext_secret %>
+ <% if secret.blank? && Doorkeeper.config.application_secret_hashed? %>
+ <%= t('.secret_hashed') %>
+ <% else %>
+ <%= secret %>
+ <% end %>
+
+
+
+
<%= t('.scopes') %>:
+
+
+ <% if @application.scopes.present? %>
+ <%= @application.scopes %>
+ <% else %>
+ <%= t('.not_defined') %>
+ <% end %>
+
+
+
+
<%= t('.confidential') %>:
+
<%= @application.confidential? %>
+
+
<%= t('.callback_urls') %>:
+
+ <% if @application.redirect_uri.present? %>
+
+ <% @application.redirect_uri.split.each do |uri| %>
+
+
+ <%= uri %>
+ |
+
+ <%= link_to t('doorkeeper.applications.buttons.authorize'), oauth_authorization_path(client_id: @application.uid, redirect_uri: uri, response_type: 'code', scope: @application.scopes), class: 'btn btn-success', target: '_blank' %>
+ |
+
+ <% end %>
+
+ <% else %>
+
<%= t('.not_defined') %>
+ <% end %>
+
+
+
+
<%= t('.actions') %>
+
+
<%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(@application), class: 'btn btn-primary' %>
+
+
<%= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger' %>
+
+
diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb
new file mode 100644
index 00000000..303ec57f
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/error.html.erb
@@ -0,0 +1,22 @@
+
+
+
+ <%= icon("alert-circle", class: "w-6 h-6 text-destructive") %>
+
+
<%= t('doorkeeper.authorizations.error.title') %>
+
+
+
+
+ <%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
+
+
+
+
+ <%= render LinkComponent.new(
+ text: "Go back",
+ href: "javascript:history.back()",
+ variant: :secondary
+ ) %>
+
+
diff --git a/app/views/doorkeeper/authorizations/form_post.html.erb b/app/views/doorkeeper/authorizations/form_post.html.erb
new file mode 100644
index 00000000..637aa663
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/form_post.html.erb
@@ -0,0 +1,22 @@
+
+
+
+ <%= icon("loader-circle", class: "w-6 h-6 text-primary animate-spin") %>
+
+
<%= t('.title') %>
+
Redirecting you back to the application...
+
+
+
+<% turbo_disabled = @pre_auth.redirect_uri&.start_with?('maybeapp://') || params[:display] == 'mobile' %>
+<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false, data: { turbo: !turbo_disabled } do %>
+ <% auth.body.compact.each do |key, value| %>
+ <%= hidden_field_tag key, value %>
+ <% end %>
+<% end %>
+
+
diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb
new file mode 100644
index 00000000..49630fec
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/new.html.erb
@@ -0,0 +1,76 @@
+<% if params[:redirect_uri]&.start_with?('maybeapp://') || params[:display] == 'mobile' %>
+
+<% end %>
+
+
+
+
+ <%= raw t('.prompt', client_name: content_tag(:span, @pre_auth.client.name, class: 'font-medium text-primary')) %>
+
+
+
+ <% if @pre_auth.scopes.count > 0 %>
+
+
<%= t('.able_to') %>:
+
+ <% @pre_auth.scopes.each do |scope| %>
+ -
+ <%= icon("check", class: "w-4 h-4 mt-0.5 text-success") %>
+ <%= t scope, scope: [:doorkeeper, :scopes] %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <% turbo_disabled = params[:redirect_uri]&.start_with?('maybeapp://') || params[:display] == 'mobile' %>
+ <%= form_tag oauth_authorization_path, method: :post, class: "w-full", data: { turbo: !turbo_disabled } do %>
+ <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
+ <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
+ <%= hidden_field_tag :state, @pre_auth.state, id: nil %>
+ <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
+ <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
+ <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
+ <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
+ <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
+ <% if params[:display].present? %>
+ <%= hidden_field_tag :display, params[:display], id: nil %>
+ <% end %>
+ <%= render ButtonComponent.new(
+ text: t('doorkeeper.authorizations.buttons.authorize'),
+ variant: :primary,
+ size: :lg,
+ full_width: true,
+ href: oauth_authorization_path,
+ data: { disable_with: "Authorizing..." }
+ ) %>
+ <% end %>
+
+ <%= form_tag oauth_authorization_path, method: :delete, class: "w-full", data: { turbo: !turbo_disabled } do %>
+ <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
+ <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
+ <%= hidden_field_tag :state, @pre_auth.state, id: nil %>
+ <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
+ <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
+ <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
+ <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
+ <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
+ <% if params[:display].present? %>
+ <%= hidden_field_tag :display, params[:display], id: nil %>
+ <% end %>
+ <%= render ButtonComponent.new(
+ text: t('doorkeeper.authorizations.buttons.deny'),
+ variant: :outline,
+ size: :lg,
+ full_width: true,
+ href: oauth_authorization_path,
+ data: { disable_with: "Denying..." }
+ ) %>
+ <% end %>
+
+
+
+ By authorizing, you allow this app to access your Maybe data according to the permissions above.
+
+
diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb
new file mode 100644
index 00000000..6a8f0399
--- /dev/null
+++ b/app/views/doorkeeper/authorizations/show.html.erb
@@ -0,0 +1,17 @@
+
+
+
+ <%= icon("check", class: "w-6 h-6 text-success") %>
+
+
<%= t('.title') %>
+
+
+
+
Authorization Code:
+
<%= params[:code] %>
+
+
+
+ Copy this code and paste it into the application.
+
+
diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.erb b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb
new file mode 100644
index 00000000..512e8ece
--- /dev/null
+++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb
@@ -0,0 +1,4 @@
+<%- submit_btn_css ||= 'btn btn-link' %>
+<%= form_tag oauth_authorized_application_path(application), method: :delete do %>
+ <%= submit_tag t('doorkeeper.authorized_applications.buttons.revoke'), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %>
+<% end %>
diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb
new file mode 100644
index 00000000..a3f5aaac
--- /dev/null
+++ b/app/views/doorkeeper/authorized_applications/index.html.erb
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ <%= t('doorkeeper.authorized_applications.index.application') %> |
+ <%= t('doorkeeper.authorized_applications.index.created_at') %> |
+ |
+
+
+
+ <% @applications.each do |application| %>
+
+ <%= application.name %> |
+ <%= application.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %> |
+ <%= render 'delete_form', application: application %> |
+
+ <% end %>
+
+
+
diff --git a/app/views/layouts/doorkeeper/admin.html.erb b/app/views/layouts/doorkeeper/admin.html.erb
new file mode 100644
index 00000000..3d1aeade
--- /dev/null
+++ b/app/views/layouts/doorkeeper/admin.html.erb
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ <%= t('doorkeeper.layouts.admin.title') %>
+ <%= stylesheet_link_tag "doorkeeper/admin/application" %>
+ <%= csrf_meta_tags %>
+
+
+
+
+
+ <%- if flash[:notice].present? %>
+
+ <%= flash[:notice] %>
+
+ <% end -%>
+
+ <%= yield %>
+
+
+
diff --git a/app/views/layouts/doorkeeper/application.html.erb b/app/views/layouts/doorkeeper/application.html.erb
new file mode 100644
index 00000000..375f1323
--- /dev/null
+++ b/app/views/layouts/doorkeeper/application.html.erb
@@ -0,0 +1,48 @@
+
+<% theme = Current.user&.theme || "system" %>
+"
+ class="h-full text-primary overflow-hidden lg:overflow-auto font-sans">
+
+ <%= render "layouts/shared/head" %>
+
+
+
+
+
+
+
+
+ <%= image_tag "logo-color.png", class: "w-16 mb-6" %>
+
+
+
+ Maybe Authorization
+
+
+
+
+
+ <%- if flash[:notice].present? %>
+
+ <%= flash[:notice] %>
+
+ <% end -%>
+ <%- if flash[:alert].present? %>
+
+ <%= flash[:alert] %>
+
+ <% end -%>
+
+ <%= yield %>
+
+
+
+ <%= render "layouts/shared/footer" %>
+
+
+
+
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 27e63978..2a400288 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -303,9 +303,8 @@ Doorkeeper.configure do
# #call can be used in order to allow conditional checks (to allow non-SSL
# redirects to localhost for example).
#
- # force_ssl_in_redirect_uri !Rails.env.development?
- #
- # force_ssl_in_redirect_uri { |uri| uri.host != 'localhost' }
+ # Allow custom URL schemes for mobile apps
+ force_ssl_in_redirect_uri false
# Specify what redirect URI's you want to block during Application creation.
# Any redirect URI is allowed by default.
@@ -313,7 +312,8 @@ Doorkeeper.configure do
# You can use this option in order to forbid URI's with 'javascript' scheme
# for example.
#
- # forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' }
+ # Block javascript URIs but allow custom schemes
+ forbid_redirect_uri { |uri| uri.scheme.to_s.downcase == 'javascript' }
# Allows to set blank redirect URIs for Applications in case Doorkeeper configured
# to use URI-less OAuth grant flows like Client Credentials or Resource Owner
diff --git a/config/initializers/doorkeeper_layout.rb b/config/initializers/doorkeeper_layout.rb
new file mode 100644
index 00000000..42ebdb9a
--- /dev/null
+++ b/config/initializers/doorkeeper_layout.rb
@@ -0,0 +1,6 @@
+# Ensure Doorkeeper controllers use the correct layout
+Rails.application.config.to_prepare do
+ Doorkeeper::AuthorizationsController.layout "doorkeeper/application"
+ Doorkeeper::AuthorizedApplicationsController.layout "doorkeeper/application"
+ Doorkeeper::ApplicationsController.layout "doorkeeper/application"
+end
\ No newline at end of file
diff --git a/test/integration/oauth_mobile_test.rb b/test/integration/oauth_mobile_test.rb
new file mode 100644
index 00000000..6c3dc275
--- /dev/null
+++ b/test/integration/oauth_mobile_test.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class OauthMobileTest < ActionDispatch::IntegrationTest
+ setup do
+ @user = users(:empty)
+ sign_in(@user)
+
+ @oauth_app = Doorkeeper::Application.create!(
+ name: "Maybe Mobile App",
+ redirect_uri: "maybeapp://oauth/callback",
+ scopes: "read"
+ )
+ end
+
+ test "mobile oauth authorization with custom scheme redirect" do
+ get "/oauth/authorize", params: {
+ client_id: @oauth_app.uid,
+ redirect_uri: @oauth_app.redirect_uri,
+ response_type: "code",
+ scope: "read",
+ display: "mobile"
+ }
+
+ assert_response :success
+
+ # Check that Turbo is disabled in the form
+ assert_match(/data-turbo="false"/, response.body)
+ assert_match(/maybeapp:\/\/oauth\/callback/, response.body)
+ end
+
+ test "mobile oauth detects custom scheme in redirect_uri" do
+ get "/oauth/authorize", params: {
+ client_id: @oauth_app.uid,
+ redirect_uri: "maybeapp://oauth/callback",
+ response_type: "code",
+ scope: "read"
+ }
+
+ assert_response :success
+
+ # Should detect mobile flow from redirect_uri
+ assert_match(/data-turbo="false"/, response.body)
+ end
+
+ test "mobile oauth authorization flow completes successfully" do
+ post "/oauth/authorize", params: {
+ client_id: @oauth_app.uid,
+ redirect_uri: @oauth_app.redirect_uri,
+ response_type: "code",
+ scope: "read",
+ display: "mobile"
+ }
+
+ # Should redirect to the custom scheme
+ assert_response :redirect
+ assert response.location.start_with?("maybeapp://oauth/callback")
+ end
+
+ test "mobile oauth preserves display parameter through forms" do
+ get "/oauth/authorize", params: {
+ client_id: @oauth_app.uid,
+ redirect_uri: @oauth_app.redirect_uri,
+ response_type: "code",
+ scope: "read",
+ display: "mobile"
+ }
+
+ assert_response :success
+
+ # Check that display parameter is preserved in hidden fields
+ assert_match(/]*name="display"[^>]*value="mobile"/, response.body)
+ end
+end