Skip to content

Commit fb9ba03

Browse files
authored
Sync general improvements from hypemarket #4 (#339)
- Fix duplicate trix/actiontext pins in importmap - Add OAuth token auto-refresh (OauthTokenRefreshable concern) - Add Google One-Tap authentication (jwt gem, verifier, controller, route) - Add OG image fallback when prefilling organization from website - Add sitemap.xml route and controller action - Add badge-count meta tag for PWA/desktop app support - Use daisyUI badge classes for notification badge - Add RichTextHelper for Lexxy code language picker - Improve after_sign_in_path_for with path_after_invitation helper
1 parent 7e9c4e0 commit fb9ba03

19 files changed

Lines changed: 278 additions & 17 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ gem "nondisposable"
8888
# oauth
8989
gem "omniauth-google-oauth2"
9090
gem "omniauth-github"
91+
gem "jwt"
9192

9293
# authorization
9394
gem "pundit", "~> 2.3"

Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,7 @@ DEPENDENCIES
689689
importmap-rails
690690
inline_svg (~> 1.9)
691691
jbuilder
692+
jwt
692693
kamal
693694
letter_opener
694695
letter_opener_web

app/controllers/concerns/authentication.rb

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,26 @@ def after_sign_in_path_for(resource)
1414
stored_location = stored_location_for(resource)
1515
return stored_location if stored_location
1616

17-
# Redirect users with pending invitations to their invitations page
18-
return user_organizations_received_invitations_path if resource.received_invitations.pending.any?
17+
invitation = resource.received_invitations.pending.first
18+
return user_organizations_received_invitation_path(invitation) if invitation
1919

20-
if resource.organizations.any?
21-
session.delete(:new_user) if session[:new_user]
22-
organization_path(resource.organizations.first)
23-
elsif session[:new_user]
20+
if session[:new_user]
2421
session.delete(:new_user)
2522
# You can send new users to onboarding, billing, or somewhere else
2623
root_path
2724
else
28-
stored_location_for(resource) || root_path
25+
default_authenticated_path
2926
end
3027
end
28+
29+
def path_after_invitation(user)
30+
next_invitation = user.received_invitations.pending.first
31+
return user_organizations_received_invitation_path(next_invitation) if next_invitation
32+
33+
default_authenticated_path
34+
end
35+
36+
def default_authenticated_path
37+
organizations_path
38+
end
3139
end

app/controllers/concerns/organization_prefill.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def prefill_organization_from_website(organization, url)
1616
name = metadata["site_name"] || metadata["title"]
1717
organization.name = name if name.present?
1818
attach_og_logo(organization, metadata["favicon_url"]) if metadata["favicon_url"].present?
19+
attach_og_logo(organization, metadata["image_url"]) if !organization.logo.attached? && metadata["image_url"].present?
1920
end
2021
rescue Timeout::Error
2122
# Prefill timed out — user can still fill in details manually

app/controllers/static_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ class StaticController < ApplicationController
55

66
def index; end
77

8+
def sitemap
9+
expires_in 12.hours, public: true
10+
end
11+
812
def pricing; end
913

1014
def terms; end

app/controllers/users/omniauth_callbacks_controller.rb

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,47 @@
11
# frozen_string_literal: true
22

33
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
4+
skip_before_action :verify_authenticity_token, only: :google_onetap
5+
46
def callback
57
provider = params[:provider].to_sym
68
handle_auth provider
79
end
810

11+
def google_onetap
12+
unless g_csrf_token_valid?
13+
redirect_to new_user_session_path, alert: I18n.t("devise.omniauth_callbacks.failure")
14+
return
15+
end
16+
17+
payload = GoogleIdTokenVerifier.verify(params[:credential])
18+
19+
unless payload["email_verified"]
20+
redirect_to new_user_session_path, alert: I18n.t("devise.omniauth_callbacks.failure")
21+
return
22+
end
23+
24+
auth_hash = OmniAuth::AuthHash.new(
25+
provider: "google_oauth2",
26+
uid: payload["sub"],
27+
info: { name: payload["name"], email: payload["email"], image: payload["picture"] }
28+
)
29+
30+
user = User.from_omniauth(auth_hash)
31+
32+
if user.persisted?
33+
if user.saved_change_to_id?
34+
session[:new_user] = true
35+
refer user
36+
end
37+
sign_in_and_redirect user, event: :authentication
38+
else
39+
redirect_to new_user_registration_url, alert: user.errors.full_messages.join("\n")
40+
end
41+
rescue JWT::DecodeError
42+
redirect_to new_user_session_path, alert: I18n.t("devise.omniauth_callbacks.failure")
43+
end
44+
945
def failure
1046
redirect_to new_user_registration_url, alert: I18n.t("devise.omniauth_callbacks.failure")
1147
end
@@ -16,15 +52,18 @@ def handle_auth(kind)
1652
auth_payload = request.env["omniauth.auth"]
1753

1854
if user_signed_in?
19-
# User is already logged in, add OAuth account to current user
2055
identity = Identity.create_or_update_from_omniauth(auth_payload, current_user)
21-
flash[:alert] = I18n.t("users.identities.errors.failed_to_connect", provider: Identity::AUTH_PROVIDERS[kind][:name], errors: identity.errors.full_messages.join(", ")) unless identity.persisted?
56+
unless identity.persisted?
57+
flash[:alert] = I18n.t("users.identities.errors.failed_to_connect",
58+
provider: Identity::AUTH_PROVIDERS[kind][:name],
59+
errors: identity.errors.full_messages.join(", "))
60+
end
2261
redirect_to user_identities_path
2362
else
2463
user = User.from_omniauth(auth_payload)
2564
if user.persisted?
2665
if user.saved_change_to_id?
27-
session[:new_user] = true if user.saved_change_to_id?
66+
session[:new_user] = true
2867
refer user
2968
end
3069
sign_in_and_redirect user, event: :authentication, remember: true
@@ -34,4 +73,9 @@ def handle_auth(kind)
3473
end
3574
end
3675
end
76+
77+
def g_csrf_token_valid?
78+
token = cookies["g_csrf_token"]
79+
token.present? && token == params["g_csrf_token"]
80+
end
3781
end

app/helpers/rich_text_helper.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
module RichTextHelper
4+
def code_language_picker
5+
content_tag "lexxy-code-language-picker"
6+
end
7+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
3+
const GSI_SRC = 'https://accounts.google.com/gsi/client'
4+
5+
export default class extends Controller {
6+
static values = {
7+
clientId: String,
8+
loginUri: String
9+
}
10+
11+
connect() {
12+
if (document.querySelector(`script[src="${GSI_SRC}"]`)) return
13+
14+
this.script = document.createElement('script')
15+
this.script.src = GSI_SRC
16+
this.script.async = true
17+
document.head.appendChild(this.script)
18+
}
19+
20+
disconnect() {
21+
this.script?.remove()
22+
}
23+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
3+
# Shared concern for models that store OAuth2 tokens and need automatic refresh.
4+
#
5+
# Models including this concern must:
6+
# - Have columns: access_token, refresh_token, expires_at, refresh_token_invalidated_at
7+
# - Define REFRESHABLE_PROVIDERS constant (array of provider/platform strings)
8+
# - Implement #oauth_provider_identifier (returns the string used for OAuth config lookup)
9+
# - Implement #oauth_token_owner (returns the user to notify on token invalidation)
10+
module OauthTokenRefreshable
11+
extend ActiveSupport::Concern
12+
13+
def token
14+
if can_refresh? && expired?
15+
with_lock do
16+
reload
17+
renew_token! if can_refresh? && expired?
18+
end
19+
end
20+
access_token
21+
end
22+
23+
def expired?
24+
return false unless expires_at?
25+
26+
expires_at <= 10.minutes.from_now
27+
end
28+
29+
def can_refresh?
30+
self.class::REFRESHABLE_PROVIDERS.include?(oauth_provider_identifier) &&
31+
refresh_token.present? &&
32+
refresh_token_invalidated_at.nil?
33+
end
34+
35+
def renew_token!
36+
new_token = current_token.refresh!
37+
update(
38+
access_token: new_token.token,
39+
refresh_token: new_token.refresh_token || refresh_token,
40+
expires_at: new_token.expires_at ? Time.zone.at(new_token.expires_at) : nil,
41+
refresh_token_invalidated_at: nil
42+
)
43+
rescue OAuth2::Error => e
44+
if e.code == "invalid_grant" || e.description&.include?("expired") || e.description&.include?("revoked")
45+
update(refresh_token_invalidated_at: Time.current)
46+
return nil
47+
end
48+
raise e
49+
end
50+
51+
private
52+
53+
def current_token
54+
OAuth2::AccessToken.new(
55+
oauth2_client,
56+
access_token,
57+
refresh_token: refresh_token
58+
)
59+
end
60+
61+
def oauth2_client
62+
config = provider_oauth_config
63+
OAuth2::Client.new(
64+
config[:client_id],
65+
config[:client_secret],
66+
site: config[:site],
67+
authorize_url: config[:authorize_url],
68+
token_url: config[:token_url]
69+
)
70+
end
71+
72+
def provider_oauth_config
73+
case oauth_provider_identifier
74+
when "google_oauth2"
75+
{
76+
client_id: Rails.application.credentials.dig(:google_oauth2, :client_id),
77+
client_secret: Rails.application.credentials.dig(:google_oauth2, :client_secret),
78+
site: "https://accounts.google.com",
79+
authorize_url: "/o/oauth2/auth",
80+
token_url: "/o/oauth2/token"
81+
}
82+
else
83+
raise NotImplementedError, "Token refresh not supported for provider: #{oauth_provider_identifier}"
84+
end
85+
end
86+
end

app/models/identity.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

33
class Identity < ApplicationRecord
4+
include OauthTokenRefreshable
5+
46
belongs_to :user
57

68
validates :provider, presence: true
@@ -62,4 +64,14 @@ def self.create_or_update_from_omniauth(auth_payload, user)
6264
def self.auth_provider?(provider)
6365
AUTH_PROVIDERS.key?(provider.to_sym)
6466
end
67+
68+
# Required by OauthTokenRefreshable
69+
def oauth_provider_identifier
70+
provider
71+
end
72+
73+
# Required by OauthTokenRefreshable
74+
def oauth_token_owner
75+
user
76+
end
6577
end

0 commit comments

Comments
 (0)