From 32d19cbe7c583a2c53f59181839065cbf7ab55b6 Mon Sep 17 00:00:00 2001 From: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:13:16 +0100 Subject: [PATCH] Add the possibility to enable/disable default OAuths (#303) --- app/assets/stylesheets/common/_index.scss | 10 +- .../SiteSettings/Authentication/index.scss | 6 + .../stylesheets/components/TenantSignUp.scss | 4 + app/controllers/likes_controller.rb | 2 +- app/controllers/o_auths_controller.rb | 40 ++++-- .../tenant_default_o_auths_controller.rb | 35 ++++++ app/controllers/tenants_controller.rb | 5 +- .../actions/OAuth/updateDefaultOAuth.ts | 75 ++++++++++++ .../AuthenticationIndexPage.tsx | 3 + .../AuthenticationSiteSettingsP.tsx | 7 ++ .../Authentication/OAuthProviderItem.tsx | 87 +++++++------ .../Authentication/OAuthProvidersList.tsx | 3 + .../components/TenantSignUp/TenantSignUpP.tsx | 26 ++-- .../TenantSignUp/UserSignUpForm.tsx | 114 ++++++++++-------- .../common/CopyToClipboardButton.tsx | 3 +- .../containers/AuthenticationSiteSettings.tsx | 5 + app/javascript/interfaces/IOAuth.ts | 4 + app/javascript/reducers/oAuthsReducer.ts | 19 ++- app/models/o_auth.rb | 23 +++- app/models/tenant.rb | 7 +- app/models/tenant_default_o_auth.rb | 11 ++ ...exchange_auth_code_for_profile_workflow.rb | 24 ++-- app/workflows/o_auth_sign_in_user_workflow.rb | 1 - config/routes.rb | 4 +- ...303103945_create_tenant_default_o_auths.rb | 10 ++ db/schema.rb | 13 +- spec/factories/o_auths.rb | 15 +++ spec/factories/tenant_default_o_auths.rb | 6 + spec/models/tenant_default_o_auth_spec.rb | 14 +++ .../site_settings_authentication_spec.rb | 14 +++ spec/system/user_o_auth_sign_up_and_log_in.rb | 49 ++++++++ 31 files changed, 508 insertions(+), 131 deletions(-) create mode 100644 app/controllers/tenant_default_o_auths_controller.rb create mode 100644 app/javascript/actions/OAuth/updateDefaultOAuth.ts create mode 100644 app/models/tenant_default_o_auth.rb create mode 100644 db/migrate/20240303103945_create_tenant_default_o_auths.rb create mode 100644 spec/factories/tenant_default_o_auths.rb create mode 100644 spec/models/tenant_default_o_auth_spec.rb create mode 100644 spec/system/user_o_auth_sign_up_and_log_in.rb diff --git a/app/assets/stylesheets/common/_index.scss b/app/assets/stylesheets/common/_index.scss index 27249b76..bc8b4bee 100644 --- a/app/assets/stylesheets/common/_index.scss +++ b/app/assets/stylesheets/common/_index.scss @@ -269,7 +269,15 @@ body { .btn-block, .btn-outline-dark, .mt-2, - .mb-2; + .mb-2, + .p-0; + + height: 38px; + + &:hover { + background-color: white; + color: var(--astuto-black); + } .oauthProviderText { @extend .ml-2; diff --git a/app/assets/stylesheets/components/SiteSettings/Authentication/index.scss b/app/assets/stylesheets/components/SiteSettings/Authentication/index.scss index 2b28fa0a..0292d7a7 100644 --- a/app/assets/stylesheets/components/SiteSettings/Authentication/index.scss +++ b/app/assets/stylesheets/components/SiteSettings/Authentication/index.scss @@ -38,6 +38,12 @@ align-self: center; } + + .defaultOAuthDiv { + @extend .d-flex; + + .defaultOAuthLabel { @extend .align-self-center; } + } } } } diff --git a/app/assets/stylesheets/components/TenantSignUp.scss b/app/assets/stylesheets/components/TenantSignUp.scss index 335173f5..f77644c0 100644 --- a/app/assets/stylesheets/components/TenantSignUp.scss +++ b/app/assets/stylesheets/components/TenantSignUp.scss @@ -10,6 +10,10 @@ text-align: center; margin-bottom: 16px; } + + .emailAuth { + @extend .mt-2, .mb-2; + } .userConfirm, .tenantConfirm { display: block; diff --git a/app/controllers/likes_controller.rb b/app/controllers/likes_controller.rb index 091e9188..f87210f4 100644 --- a/app/controllers/likes_controller.rb +++ b/app/controllers/likes_controller.rb @@ -11,7 +11,7 @@ class LikesController < ApplicationController .left_outer_joins(:user) .where(post_id: params[:post_id]) - render json: likes + render json: likes end def create diff --git a/app/controllers/o_auths_controller.rb b/app/controllers/o_auths_controller.rb index f5837ee6..3a618271 100644 --- a/app/controllers/o_auths_controller.rb +++ b/app/controllers/o_auths_controller.rb @@ -5,12 +5,16 @@ class OAuthsController < ApplicationController before_action :authenticate_admin, only: [:index, :create, :update, :destroy] - TOKEN_STATE_SEPARATOR = '-' + TOKEN_STATE_SEPARATOR = ',' # [subdomain.]base_url/o_auths/:id/start?reason=login|test|tenantsignup # Generates authorize url with required parameters and redirects to provider def start - @o_auth = OAuth.unscoped.include_defaults.find(params[:id]) + if params[:reason] == 'tenantsignup' + @o_auth = OAuth.include_only_defaults.find(params[:id]) + else + @o_auth = OAuth.include_defaults.find(params[:id]) + end return if params[:reason] != 'test' and not @o_auth.is_enabled? @@ -31,15 +35,17 @@ class OAuthsController < ApplicationController return unless cookies[:token_state] == params[:state] cookies.delete(:token_state, domain: ".#{request.domain}") - @o_auth = OAuth.unscoped.include_defaults.find(params[:id]) + # if it is a default oauth, tenant is not yet set + Current.tenant ||= Tenant.find_by(subdomain: tenant_domain) + + if reason == 'tenantsignup' + @o_auth = OAuth.include_only_defaults.find(params[:id]) + else + @o_auth = OAuth.include_defaults.find(params[:id]) + end return if reason != 'test' and not @o_auth.is_enabled? - # If it is a default OAuth we need to set the tenant - if @o_auth.is_default? - Current.tenant = Tenant.find_by(subdomain: tenant_domain) - end - user_profile = OAuthExchangeAuthCodeForProfileWorkflow.new( authorization_code: params[:code], o_auth: @o_auth @@ -80,12 +86,20 @@ class OAuthsController < ApplicationController elsif reason == 'tenantsignup' - @o_auths = [] + @o_auths = @o_auths = OAuth.unscoped.where(tenant_id: nil, is_enabled: true) + @user_email = query_path_from_object(user_profile, @o_auth.json_user_email_path) if not @o_auth.json_user_name_path.blank? @user_name = query_path_from_object(user_profile, @o_auth.json_user_name_path) end - @o_auth_login_completed = true + + @o_auth_login_completed = (not @user_email.blank?) + + if not @o_auth_login_completed + flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name) + redirect_to signup_url + return + end session[:o_auth_sign_up] = "#{@user_email},#{@user_name}" @@ -124,7 +138,9 @@ class OAuthsController < ApplicationController def index authorize OAuth - @o_auths = OAuth.include_defaults.order(created_at: :asc) + @o_auths = OAuth + .include_all_defaults + .order(tenant_id: :asc, created_at: :asc) render json: to_json_custom(@o_auths) end @@ -175,7 +191,7 @@ class OAuthsController < ApplicationController def to_json_custom(o_auth) o_auth.as_json( - methods: :callback_url, + methods: [:callback_url, :default_o_auth_is_enabled], except: [:client_secret] ) end diff --git a/app/controllers/tenant_default_o_auths_controller.rb b/app/controllers/tenant_default_o_auths_controller.rb new file mode 100644 index 00000000..4c6bf5b9 --- /dev/null +++ b/app/controllers/tenant_default_o_auths_controller.rb @@ -0,0 +1,35 @@ +class TenantDefaultOAuthsController < ApplicationController + include ApplicationHelper + + before_action :authenticate_admin, only: [:create, :destroy] + + def create + enabled_default_oauth = TenantDefaultOAuth.new(o_auth_id: params[:o_auth_id]) + + if enabled_default_oauth.save + render json: { + id: params[:o_auth_id] + }, status: :created + else + render json: { + error: enabled_default_oauth.errors.full_messages + }, status: :unprocessable_entity + end + end + + def destroy + enabled_default_oauth = TenantDefaultOAuth.find_by(o_auth_id: params[:o_auth_id]) + + return if enabled_default_oauth.nil? + + if enabled_default_oauth.destroy + render json: { + id: params[:o_auth_id], + }, status: :accepted + else + render json: { + error: enabled_default_oauth.errors.full_messages + }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb index f9cc2519..71eff183 100644 --- a/app/controllers/tenants_controller.rb +++ b/app/controllers/tenants_controller.rb @@ -5,7 +5,7 @@ class TenantsController < ApplicationController def new @page_title = "Create your feedback space" - @o_auths = OAuth.unscoped.where(tenant_id: nil) + @o_auths = OAuth.unscoped.where(tenant_id: nil, is_enabled: true) end def show @@ -46,6 +46,9 @@ class TenantsController < ApplicationController @user.save! CreateWelcomeEntitiesWorkflow.new().run + OAuth.include_only_defaults.each do |o_auth| + TenantDefaultOAuth.create(o_auth_id: o_auth.id) + end logger.info { "New tenant registration: #{Current.tenant.inspect}" } diff --git a/app/javascript/actions/OAuth/updateDefaultOAuth.ts b/app/javascript/actions/OAuth/updateDefaultOAuth.ts new file mode 100644 index 00000000..5fae726e --- /dev/null +++ b/app/javascript/actions/OAuth/updateDefaultOAuth.ts @@ -0,0 +1,75 @@ +import { Action } from "redux"; +import { ThunkAction } from "redux-thunk"; + +import { IOAuthJSON } from "../../interfaces/IOAuth"; +import { State } from "../../reducers/rootReducer"; +import buildRequestHeaders from "../../helpers/buildRequestHeaders"; +import HttpStatus from "../../constants/http_status"; + +export const DEFAULT_OAUTH_UPDATE_START = 'DEFAULT_OAUTH_UPDATE_START'; +interface DefaultOAuthUpdateStartAction { + type: typeof DEFAULT_OAUTH_UPDATE_START; +} + +export const DEFAULT_OAUTH_UPDATE_SUCCESS = 'DEFAULT_OAUTH_UPDATE_SUCCESS'; +interface DefaultOAuthUpdateSuccessAction { + type: typeof DEFAULT_OAUTH_UPDATE_SUCCESS; + id: number; + isEnabled: boolean; +} + +export const DEFAULT_OAUTH_UPDATE_FAILURE = 'DEFAULT_OAUTH_UPDATE_FAILURE'; +interface DefaultOAuthUpdateFailureAction { + type: typeof DEFAULT_OAUTH_UPDATE_FAILURE; + error: string; +} + +export type DefaultOAuthUpdateActionTypes = + DefaultOAuthUpdateStartAction | + DefaultOAuthUpdateSuccessAction | + DefaultOAuthUpdateFailureAction; + +const defaultOAuthUpdateStart = (): DefaultOAuthUpdateStartAction => ({ + type: DEFAULT_OAUTH_UPDATE_START, +}); + +const defaultOAuthUpdateSuccess = ( + id: number, + isEnabled: boolean, +): DefaultOAuthUpdateSuccessAction => ({ + type: DEFAULT_OAUTH_UPDATE_SUCCESS, + id, + isEnabled, +}); + +const defaultOAuthUpdateFailure = (error: string): DefaultOAuthUpdateFailureAction => ({ + type: DEFAULT_OAUTH_UPDATE_FAILURE, + error, +}); + +interface UpdateDefaultOAuthParams { + id: number; + isEnabled?: boolean; + authenticityToken: string; +} + +export const updateDefaultOAuth = ({ + id, + isEnabled = null, + authenticityToken, +}: UpdateDefaultOAuthParams): ThunkAction> => async (dispatch) => { + try { + dispatch(defaultOAuthUpdateStart()); + + const res = await fetch(`/o_auths/${id}/tenant_default_o_auths`, { + method: isEnabled ? 'POST' : 'DELETE', + headers: buildRequestHeaders(authenticityToken), + }); + await res.json(); + + if (res.status === HttpStatus.Created || res.status === HttpStatus.Accepted) + dispatch(defaultOAuthUpdateSuccess(id, isEnabled)); + } catch (e) { + console.log('An error occurred while enabling/disabling default OAuth'); + } +}; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx b/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx index 1a98720d..66452566 100644 --- a/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx +++ b/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx @@ -15,6 +15,7 @@ interface Props { submitError: string; handleToggleEnabledOAuth(id: number, enabled: boolean): void; + handleToggleEnabledDefaultOAuth(id: number, enabled: boolean): void; handleDeleteOAuth(id: number): void; setPage: React.Dispatch>; @@ -27,6 +28,7 @@ const AuthenticationIndexPage = ({ submitError, handleToggleEnabledOAuth, + handleToggleEnabledDefaultOAuth, handleDeleteOAuth, setPage, @@ -48,6 +50,7 @@ const AuthenticationIndexPage = ({ ; onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise; onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; + onToggleEnabledDefaultOAuth(id: number, isEnabled: boolean, authenticityToken: string): void; onDeleteOAuth(id: number, authenticityToken: string): void; isSubmitting: boolean; @@ -32,6 +33,7 @@ const AuthenticationSiteSettingsP = ({ onSubmitOAuth, onUpdateOAuth, onToggleEnabledOAuth, + onToggleEnabledDefaultOAuth, onDeleteOAuth, isSubmitting, submitError, @@ -58,6 +60,10 @@ const AuthenticationSiteSettingsP = ({ onToggleEnabledOAuth(id, enabled, authenticityToken); }; + const handleToggleEnabledDefaultOAuth = (id: number, enabled: boolean) => { + onToggleEnabledDefaultOAuth(id, enabled, authenticityToken); + }; + const handleDeleteOAuth = (id: number) => { onDeleteOAuth(id, authenticityToken); }; @@ -67,6 +73,7 @@ const AuthenticationSiteSettingsP = ({ >; setSelectedOAuth: React.Dispatch>; @@ -20,6 +21,7 @@ interface Props { const OAuthProviderItem = ({ oAuth, handleToggleEnabledOAuth, + handleToggleEnabledDefaultOAuth, handleDeleteOAuth, setPage, setSelectedOAuth, @@ -41,48 +43,59 @@ const OAuthProviderItem = ({ /> : -
{I18n.t('site_settings.authentication.default_oauth')}
+
+ handleToggleEnabledDefaultOAuth(oAuth.id, !oAuth.defaultOAuthIsEnabled)} + checked={oAuth.defaultOAuthIsEnabled} + htmlId={`oAuth${oAuth.name}EnabledSwitch`} + /> +
} { - oAuth.tenantId && -
- - - - window.open(`/o_auths/${oAuth.id}/start?reason=test`, '', 'width=640, height=640') - } - icon={} - customClass='testAction' - > - {I18n.t('common.buttons.test')} - - - { - setSelectedOAuth(oAuth.id); - setPage('edit'); - }} - icon={} - customClass='editAction' - > - {I18n.t('common.buttons.edit')} - - - confirm(I18n.t('common.confirmation')) && handleDeleteOAuth(oAuth.id)} - icon={} - customClass='deleteAction' - > - {I18n.t('common.buttons.delete')} - -
+ oAuth.tenantId ? +
+ + + + window.open(`/o_auths/${oAuth.id}/start?reason=test`, '', 'width=640, height=640') + } + icon={} + customClass='testAction' + > + {I18n.t('common.buttons.test')} + + + { + setSelectedOAuth(oAuth.id); + setPage('edit'); + }} + icon={} + customClass='editAction' + > + {I18n.t('common.buttons.edit')} + + + confirm(I18n.t('common.confirmation')) && handleDeleteOAuth(oAuth.id)} + icon={} + customClass='deleteAction' + > + {I18n.t('common.buttons.delete')} + +
+ : +
+ {I18n.t('site_settings.authentication.default_oauth')} +
} ); diff --git a/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx b/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx index 9a9e4916..7271f6d7 100644 --- a/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx +++ b/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx @@ -9,6 +9,7 @@ import OAuthProviderItem from './OAuthProviderItem'; interface Props { oAuths: Array; handleToggleEnabledOAuth(id: number, enabled: boolean): void; + handleToggleEnabledDefaultOAuth(id: number, enabled: boolean): void; handleDeleteOAuth(id: number): void; setPage: React.Dispatch>; setSelectedOAuth: React.Dispatch>; @@ -17,6 +18,7 @@ interface Props { const OAuthProvidersList = ({ oAuths, handleToggleEnabledOAuth, + handleToggleEnabledDefaultOAuth, handleDeleteOAuth, setPage, setSelectedOAuth, @@ -35,6 +37,7 @@ const OAuthProvidersList = ({ { + // authMethod is either 'none', 'email' or 'oauth' + const [authMethod, setAuthMethod] = useState(oAuthLoginCompleted ? 'oauth' : 'none'); + const [userData, setUserData] = useState({ - fullName: '', - email: '', + fullName: oAuthLoginCompleted ? oauthUserName : '', + email: oAuthLoginCompleted ? oauthUserEmail : '', password: '', passwordConfirmation: '', }); @@ -72,20 +77,18 @@ const TenantSignUpP = ({ const [currentStep, setCurrentStep] = useState(oAuthLoginCompleted ? 2 : 1); - const [emailAuth, setEmailAuth] = useState(false); - const handleSignUpSubmit = (siteName: string, subdomain: string) => { handleSubmit( - oAuthLoginCompleted ? oauthUserName : userData.fullName, - oAuthLoginCompleted ? oauthUserEmail : userData.email, + userData.fullName, + userData.email, userData.password, siteName, subdomain, - oAuthLoginCompleted, + authMethod == 'oauth', authenticityToken, ).then(res => { if (res?.status !== HttpStatus.Created) return; - if (oAuthLoginCompleted) { + if (authMethod == 'oauth') { let redirectUrl = new URL(baseUrl); redirectUrl.hostname = `${subdomain}.${redirectUrl.hostname}`; window.location.href = `${redirectUrl.toString()}users/sign_in`; @@ -107,12 +110,9 @@ const TenantSignUpP = ({ diff --git a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx index 0b801d97..9ca2ebb7 100644 --- a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx @@ -5,7 +5,7 @@ import I18n from 'i18n-js'; import Box from '../common/Box'; import Button from '../common/Button'; import OAuthProviderLink from '../common/OAuthProviderLink'; -import { ITenantSignUpUserForm } from './TenantSignUpP'; +import { AuthMethod, ITenantSignUpUserForm } from './TenantSignUpP'; import { DangerText } from '../common/CustomTexts'; import { getLabel, getValidationMessage } from '../../helpers/formUtils'; import { EMAIL_REGEX } from '../../constants/regex'; @@ -16,12 +16,9 @@ import { BackIcon, EditIcon } from '../common/Icons'; interface Props { currentStep: number; setCurrentStep(step: number): void; - emailAuth: boolean; - setEmailAuth(enabled: boolean): void; + authMethod: AuthMethod; + setAuthMethod(method: AuthMethod): void; oAuths: Array; - oAuthLoginCompleted: boolean; - oauthUserEmail?: string; - oauthUserName?: string; userData: ITenantSignUpUserForm; setUserData({}: ITenantSignUpUserForm): void; } @@ -29,12 +26,9 @@ interface Props { const UserSignUpForm = ({ currentStep, setCurrentStep, - emailAuth, - setEmailAuth, + authMethod, + setAuthMethod, oAuths, - oAuthLoginCompleted, - oauthUserEmail, - oauthUserName, userData, setUserData, }: Props) => { @@ -44,7 +38,15 @@ const UserSignUpForm = ({ setError, getValues, formState: { errors } - } = useForm(); + } = useForm({ + defaultValues: { + fullName: userData.fullName, + email: userData.email, + password: userData.password, + passwordConfirmation: userData.passwordConfirmation, + } + }); + const onSubmit: SubmitHandler = data => { if (data.password !== data.passwordConfirmation) { setError('passwordConfirmation', I18n.t('common.validations.password_mismatch')); @@ -60,36 +62,40 @@ const UserSignUpForm = ({

Create user account

{ - currentStep === 1 && !emailAuth && + currentStep === 1 && authMethod == 'none' && <> - - { - oAuths.filter(oAuth => oAuth.isEnabled).map((oAuth, i) => - - ) - } + { oAuths.length > 0 &&
} + +
+ { + oAuths.filter(oAuth => oAuth.isEnabled).map((oAuth, i) => + + ) + } +
} { - currentStep === 1 && emailAuth && + currentStep === 1 && (authMethod == 'email' || authMethod == 'oauth') &&
setEmailAuth(false)} + onClick={() => setAuthMethod('none')} icon={} customClass="backButton" > - {I18n.t('common.buttons.back')} + Use another method
@@ -106,6 +112,7 @@ const UserSignUpForm = ({
-
-
- - { errors.password && I18n.t('common.validations.password', { n: 6 }) } -
+ { + authMethod == 'email' && +
+
+ + { errors.password && I18n.t('common.validations.password', { n: 6 }) } +
-
- - { errors.passwordConfirmation && I18n.t('common.validations.password_mismatch') } +
+ + { errors.passwordConfirmation && I18n.t('common.validations.password_mismatch') } +
-
+ }