FEATURE: add link to "associated accounts" providers (#33275)

This adds a link for each authentication providers that are listed in
/my/preferences/account in the "Associated Accounts" section.

This is particularly useful when Discourse is being used in the PWA or
in the DiscourseMobile app where there's no browser bar available and
the only way to visit the provider's website is to open a browser
window.

That way, they can _just_ click the provider's name.

Internal ref - t/156255

---

**BEFORE**

![Screenshot 2025-06-19 at 21 04
46](https://github.com/user-attachments/assets/3d2be5d0-d857-4b8a-b0a5-5672301c59c6)

**AFTER**

![Screenshot 2025-06-19 at 21 03
39](https://github.com/user-attachments/assets/4c8bc5e9-3c99-4924-8d33-547f567bb346)
This commit is contained in:
Régis Hanol
2025-06-20 10:22:29 +02:00
committed by GitHub
parent 1a9f577044
commit 9dadc0141c
13 changed files with 151 additions and 38 deletions

View File

@ -87,16 +87,12 @@ export default class AccountController extends Controller {
@discourseComputed("model.associated_accounts.[]") @discourseComputed("model.associated_accounts.[]")
authProviders(accounts) { authProviders(accounts) {
const allMethods = findAll(); return findAll()
.map((method) => ({
const result = allMethods.map((method) => {
return {
method, method,
account: accounts.find((account) => account.name === method.name), // Will be undefined if no account account: accounts.find(({ name }) => name === method.name),
}; }))
}); .filter((value) => value.account || value.method.can_connect);
return result.filter((value) => value.account || value.method.can_connect);
} }
@discourseComputed( @discourseComputed(

View File

@ -168,7 +168,17 @@ export default RouteTemplate(
</td> </td>
<td> <td>
<div class="associated-account__name"> <div class="associated-account__name">
{{authProvider.method.prettyName}} {{#if authProvider.method.provider_url}}
<a
href={{authProvider.method.provider_url}}
rel="noopener noreferrer"
target="_blank"
>
{{authProvider.method.prettyName}}
</a>
{{else}}
{{authProvider.method.prettyName}}
{{/if}}
</div> </div>
<div class="associated-account__description"> <div class="associated-account__description">
{{authProvider.account.description}} {{authProvider.account.description}}
@ -206,7 +216,17 @@ export default RouteTemplate(
</td> </td>
<td> <td>
<div class="associated-account__name"> <div class="associated-account__name">
{{authProvider.method.prettyName}} {{#if authProvider.method.provider_url}}
<a
href={{authProvider.method.provider_url}}
rel="noopener noreferrer"
target="_blank"
>
{{authProvider.method.prettyName}}
</a>
{{else}}
{{authProvider.method.prettyName}}
{{/if}}
</div> </div>
<div class="associated-account__description"> <div class="associated-account__description">
{{authProvider.account.description}} {{authProvider.account.description}}

View File

@ -1,28 +1,27 @@
# frozen_string_literal: true # frozen_string_literal: true
class AuthProviderSerializer < ApplicationSerializer class AuthProviderSerializer < ApplicationSerializer
attributes :name, attributes :can_connect,
:custom_url,
:pretty_name_override,
:title_override,
:frame_width,
:frame_height,
:can_connect,
:can_revoke, :can_revoke,
:icon :custom_url,
:frame_height,
:frame_width,
:icon,
:name,
:pretty_name_override,
:provider_url,
:title_override
def title_override # ensures that the "/custom" route doesn't trigger the magic custom_url helper in ActionDispatch
return SiteSetting.get(object.title_setting) if object.title_setting def custom_url
object.title object.custom_url
end end
def pretty_name_override def pretty_name_override
return SiteSetting.get(object.pretty_name_setting) if object.pretty_name_setting object.pretty_name_setting ? SiteSetting.get(object.pretty_name_setting) : object.pretty_name
object.pretty_name
end end
def custom_url def title_override
# ensures that the "/custom" route doesn't trigger the magic custom_url helper in ActionDispatch object.title_setting ? SiteSetting.get(object.title_setting) : object.title
object.custom_url
end end
end end

View File

@ -10,23 +10,19 @@ class Auth::AuthProvider
def self.auth_attributes def self.auth_attributes
%i[ %i[
authenticator authenticator
pretty_name
title
frame_width
frame_height
pretty_name_setting
title_setting
custom_url custom_url
frame_height
frame_width
icon icon
pretty_name
pretty_name_setting
title
title_setting
] ]
end end
attr_accessor(*auth_attributes) attr_accessor(*auth_attributes)
def name
authenticator.name
end
def can_connect def can_connect
authenticator.can_connect_existing_user? authenticator.can_connect_existing_user?
end end
@ -34,4 +30,12 @@ class Auth::AuthProvider
def can_revoke def can_revoke
authenticator.can_revoke? authenticator.can_revoke?
end end
def name
authenticator.name
end
def provider_url
authenticator.provider_url
end
end end

View File

@ -13,6 +13,11 @@ class Auth::Authenticator
name name
end end
# Used in /my/preferences/account to link to the provider's website
def provider_url
nil
end
def enabled? def enabled?
raise NotImplementedError raise NotImplementedError
end end

View File

@ -46,6 +46,10 @@ class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator
"Discord" "Discord"
end end
def provider_url
"https://discord.com"
end
def enabled? def enabled?
SiteSetting.enable_discord_logins? SiteSetting.enable_discord_logins?
end end

View File

@ -33,6 +33,10 @@ class Auth::DiscourseIdAuthenticator < Auth::ManagedAuthenticator
"Discourse ID" "Discourse ID"
end end
def provider_url
site
end
def enabled? def enabled?
SiteSetting.enable_discourse_id && SiteSetting.discourse_id_client_id.present? && SiteSetting.enable_discourse_id && SiteSetting.discourse_id_client_id.present? &&
SiteSetting.discourse_id_client_secret.present? SiteSetting.discourse_id_client_secret.present?

View File

@ -11,6 +11,10 @@ class Auth::FacebookAuthenticator < Auth::ManagedAuthenticator
"Facebook" "Facebook"
end end
def provider_url
"https://www.facebook.com"
end
def enabled? def enabled?
SiteSetting.enable_facebook_logins SiteSetting.enable_facebook_logins
end end

View File

@ -11,6 +11,10 @@ class Auth::GithubAuthenticator < Auth::ManagedAuthenticator
"GitHub" "GitHub"
end end
def provider_url
"https://github.com"
end
def enabled? def enabled?
SiteSetting.enable_github_logins SiteSetting.enable_github_logins
end end

View File

@ -14,6 +14,10 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator
"Google" "Google"
end end
def provider_url
"https://accounts.google.com"
end
def enabled? def enabled?
SiteSetting.enable_google_oauth2_logins SiteSetting.enable_google_oauth2_logins
end end

View File

@ -49,6 +49,10 @@ class Auth::LinkedInOidcAuthenticator < Auth::ManagedAuthenticator
"LinkedIn" "LinkedIn"
end end
def provider_url
"https://www.linkedin.com"
end
def enabled? def enabled?
SiteSetting.enable_linkedin_oidc_logins SiteSetting.enable_linkedin_oidc_logins
end end

View File

@ -9,6 +9,10 @@ class Auth::TwitterAuthenticator < Auth::ManagedAuthenticator
"X / Twitter" "X / Twitter"
end end
def provider_url
"https://x.com"
end
def enabled? def enabled?
SiteSetting.enable_twitter_logins SiteSetting.enable_twitter_logins
end end

View File

@ -1,9 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
describe "User preferences | Avatar", type: :system do describe "User preferences | Account", type: :system do
fab!(:user) { Fabricate(:user, refresh_auto_groups: true) } fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
let(:user_account_preferences_page) { PageObjects::Pages::UserPreferencesAccount.new } let(:user_account_preferences_page) { PageObjects::Pages::UserPreferencesAccount.new }
let(:avatar_selector_modal) { PageObjects::Modals::AvatarSelector.new } let(:avatar_selector_modal) { PageObjects::Modals::AvatarSelector.new }
before { sign_in(user) } before { sign_in(user) }
describe "avatar-selector modal" do describe "avatar-selector modal" do
@ -33,4 +34,64 @@ describe "User preferences | Avatar", type: :system do
expect(avatar_selector_modal).to have_no_avatar_upload_button expect(avatar_selector_modal).to have_no_avatar_upload_button
end end
end end
describe "external login provider URLs" do
it "shows provider URLs as links when available" do
SiteSetting.enable_discord_logins = true
SiteSetting.enable_facebook_logins = true
SiteSetting.enable_github_logins = true
SiteSetting.enable_google_oauth2_logins = true
# Let's connect at least 1 external account
UserAssociatedAccount.create!(
user:,
provider_name: "google_oauth2",
provider_uid: "123456",
info: {
"email" => user.email,
},
)
user_account_preferences_page.visit(user)
name = find(".pref-associated-accounts table tr.discord .associated-account__name")
expect(name).to have_link("Discord", href: "https://discord.com")
name = find(".pref-associated-accounts table tr.facebook .associated-account__name")
expect(name).to have_link("Facebook", href: "https://www.facebook.com")
name = find(".pref-associated-accounts table tr.github .associated-account__name")
expect(name).to have_link("GitHub", href: "https://github.com")
name = find(".pref-associated-accounts table tr.google-oauth2 .associated-account__name")
expect(name).to have_link("Google", href: "https://accounts.google.com")
end
it "shows provider names without links when provider_url is not implemented" do
begin
authenticator =
Class
.new(Auth::ManagedAuthenticator) do
def name
"test_no_url"
end
def enabled?
true
end
end
.new
provider = Auth::AuthProvider.new(authenticator:, icon: "flash")
DiscoursePluginRegistry.register_auth_provider(provider)
user_account_preferences_page.visit(user)
name = find(".pref-associated-accounts table tr.test-no-url .associated-account__name")
expect(name).not_to have_css("a")
ensure
DiscoursePluginRegistry.reset!
end
end
end
end end