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.[]")
authProviders(accounts) {
const allMethods = findAll();
const result = allMethods.map((method) => {
return {
return findAll()
.map((method) => ({
method,
account: accounts.find((account) => account.name === method.name), // Will be undefined if no account
};
});
return result.filter((value) => value.account || value.method.can_connect);
account: accounts.find(({ name }) => name === method.name),
}))
.filter((value) => value.account || value.method.can_connect);
}
@discourseComputed(

View File

@ -168,7 +168,17 @@ export default RouteTemplate(
</td>
<td>
<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 class="associated-account__description">
{{authProvider.account.description}}
@ -206,7 +216,17 @@ export default RouteTemplate(
</td>
<td>
<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 class="associated-account__description">
{{authProvider.account.description}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
# 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) }
let(:user_account_preferences_page) { PageObjects::Pages::UserPreferencesAccount.new }
let(:avatar_selector_modal) { PageObjects::Modals::AvatarSelector.new }
before { sign_in(user) }
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
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