diff --git a/app/assets/stylesheets/common/_index.scss b/app/assets/stylesheets/common/_index.scss index 342bff20..4face863 100644 --- a/app/assets/stylesheets/common/_index.scss +++ b/app/assets/stylesheets/common/_index.scss @@ -208,4 +208,19 @@ text-decoration: none !important; cursor: not-allowed; } +} + +.oauthProviderBtn { + @extend + .btn, + .btn-block, + .btn-outline-dark, + .mt-2, + .mb-2; + + .oauthProviderText { + @extend .ml-2; + + vertical-align: middle; + } } \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 39de667a..1af6a14b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -49,7 +49,8 @@ class ApplicationController < ActionController::Base end def load_oauths - @o_auths = Current.tenant_or_raise!.o_auths + @o_auths = OAuth + .include_defaults .where(is_enabled: true) .order(created_at: :asc) end diff --git a/app/controllers/o_auths_controller.rb b/app/controllers/o_auths_controller.rb index 115a44c3..843cc376 100644 --- a/app/controllers/o_auths_controller.rb +++ b/app/controllers/o_auths_controller.rb @@ -7,15 +7,17 @@ class OAuthsController < ApplicationController TOKEN_STATE_SEPARATOR = '-' - # [subdomain.]base_url/o_auths/:id/start?reason=user|test + # [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.find(params[:id]) - return if params[:reason] == 'user' and not @o_auth.is_enabled? + @o_auth = OAuth.unscoped.include_defaults.find(params[:id]) + + return if params[:reason] != 'test' and not @o_auth.is_enabled? # Generate random state + other query params - token_state = "#{params[:reason]}#{TOKEN_STATE_SEPARATOR}#{Devise.friendly_token(30)}" - session[:token_state] = token_state + tenant_domain = Current.tenant ? Current.tenant_or_raise!.subdomain : "null" + token_state = "#{params[:reason]}#{TOKEN_STATE_SEPARATOR}#{tenant_domain}#{TOKEN_STATE_SEPARATOR}#{Devise.friendly_token(30)}" + cookies[:token_state] = { value: token_state, domain: ".#{request.domain}", httponly: true } @o_auth.state = token_state redirect_to @o_auth.authorize_url_with_query_params @@ -24,32 +26,42 @@ class OAuthsController < ApplicationController # [subdomain.]base_url/o_auths/:id/callback # Exchange authorization code for access token, fetch user info and sign in/up def callback - reason, token_state = params[:state].split(TOKEN_STATE_SEPARATOR, 2) + reason, tenant_domain, token_state = params[:state].split(TOKEN_STATE_SEPARATOR, 3) - return unless session[:token_state] == params[:state] + return unless cookies[:token_state] == params[:state] + cookies.delete(:token_state, domain: ".#{request.domain}") - @o_auth = OAuth.find(params[:id]) + @o_auth = OAuth.unscoped.include_defaults.find(params[:id]) + + 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 = OAuthExchangeAuthCodeForProfile.new( authorization_code: params[:code], o_auth: @o_auth ).run - if reason == 'user' + if reason == 'login' + user = OAuthSignInUser.new( user_profile: user_profile, o_auth: @o_auth ).run if user - sign_in user - flash[:notice] = I18n.t('devise.sessions.signed_in') - redirect_to root_path + oauth_token = user.generate_oauth_token + redirect_to add_subdomain_to(method(:o_auth_sign_in_from_oauth_token_url), nil, {user_id: user.id, token: oauth_token}) else flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name) - redirect_to new_user_session_path + redirect_to add_subdomain_to(method(:new_user_session_url)) end + elsif reason == 'test' + unless user_signed_in? and current_user.admin? flash[:alert] = I18n.t('errors.unauthorized') redirect_to root_url @@ -57,15 +69,53 @@ class OAuthsController < ApplicationController end @user_profile = user_profile - @user_email = query_path_from_hash(user_profile, @o_auth.json_user_email_path) + @user_email = query_path_from_object(user_profile, @o_auth.json_user_email_path) @email_valid = URI::MailTo::EMAIL_REGEXP.match?(@user_email) - @user_name = query_path_from_hash(user_profile, @o_auth.json_user_name_path) - @name_valid = !@user_name.nil? + if not @o_auth.json_user_name_path.blank? + @user_name = query_path_from_object(user_profile, @o_auth.json_user_name_path) + @name_valid = !@user_name.nil? + end render 'o_auths/test', layout: false + + elsif reason == 'tenantsignup' + + @o_auths = [] + @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 + + session[:o_auth_sign_up] = "#{@user_email},#{@user_name}" + + render 'tenants/new' + else + flash[:alert] = I18n.t('errors.unknown') redirect_to root_url + + end + end + + # [subdomain.]base_url/o_auths/sign_in_from_oauth_token?user_id=&token= + # Used for OAuth with reason 'login' + # It has been introduced because of default OAuth providers, + # since they must redirect to a common domain for all tenants. + def sign_in_from_oauth_token + return unless params[:user_id] and params[:token] + + user = User.find(params[:user_id]) + + if user.oauth_token == params[:token] + sign_in user + user.invalidate_oauth_token + flash[:notice] = I18n.t('devise.sessions.signed_in') + redirect_to root_path + else + flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name) + redirect_to new_user_session_path end end @@ -74,7 +124,7 @@ class OAuthsController < ApplicationController def index authorize OAuth - @o_auths = OAuth.order(created_at: :asc) + @o_auths = OAuth.include_defaults.order(created_at: :asc) render json: to_json_custom(@o_auths) end diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb index d6c352e8..fce64686 100644 --- a/app/controllers/tenants_controller.rb +++ b/app/controllers/tenants_controller.rb @@ -5,6 +5,7 @@ class TenantsController < ApplicationController def new @page_title = t('signup.page_title') + @o_auths = OAuth.unscoped.where(tenant_id: nil) end def show @@ -16,17 +17,34 @@ class TenantsController < ApplicationController @tenant.assign_attributes(tenant_create_params) authorize @tenant + is_o_auth_login = params[:settings][:is_o_auth_login] + ActiveRecord::Base.transaction do + if is_o_auth_login + # Check if OAuth email and username coincide with submitted ones + # (session[:o_auth_sign_up] set in oauth#callback) + email, username = session[:o_auth_sign_up].split(",", 2) + raise "Mismatching email in OAuth login" unless email == params[:user][:email] + + @tenant.status = "active" # no need to verify email address if logged in with oauth + end + @tenant.save! Current.tenant = @tenant - - @user = User.create!( - full_name: params[:user][:full_name], + + @user = User.new( + full_name: params[:user][:full_name] || I18n.t('defaults.user_full_name'), email: params[:user][:email], - password: params[:user][:password], + password: is_o_auth_login ? Devise.friendly_token : params[:user][:password], role: "owner" ) - + + if is_o_auth_login + @user.skip_confirmation + end + + @user.save! + render json: @tenant, status: :created rescue ActiveRecord::RecordInvalid => exception diff --git a/app/helpers/o_auths_helper.rb b/app/helpers/o_auths_helper.rb index d21ed33b..d6ba0919 100644 --- a/app/helpers/o_auths_helper.rb +++ b/app/helpers/o_auths_helper.rb @@ -1,26 +1,34 @@ module OAuthsHelper - def query_path_from_hash(hash, path) - return nil unless hash.class == Hash and path.class == String - - path_array = path - .split(Regexp.union([ '.', '[', ']' ])) # split by . and [] - .filter { |v| not v.blank? } # remove possible blank values - .map do |v| # convert indexes to integer - if v == "0" - 0 - elsif v.to_i == 0 - v - else - v.to_i + # Retrieves a value from a hash/array using a given path. + # obj [Hash, Array] The object or hash to query. + # path [String] The path to the desired value, using dot notation for nested keys and square brackets for array indexes. + # returns [Object, nil] The value at the specified path, or nil if the path is invalid or the value is not found. + def query_path_from_object(obj, path) + return nil unless obj.class == Hash or obj.class == Array + return nil unless path.class == String + begin + path_array = path + .split(Regexp.union([ '.', '[', ']' ])) # split by . and [] + .filter { |v| not v.blank? } # remove possible blank values + .map do |v| # convert indexes to integer + if v == "0" + 0 + elsif v.to_i == 0 + v + else + v.to_i + end end + + path_array.each do |selector| + break if obj == nil + + obj = obj[selector] end - - path_array.each do |selector| - break if hash == nil - hash = hash[selector] + obj + rescue + nil end - - hash end end \ No newline at end of file diff --git a/app/javascript/actions/Tenant/submitTenant.ts b/app/javascript/actions/Tenant/submitTenant.ts index 679f19a3..7c71fec1 100644 --- a/app/javascript/actions/Tenant/submitTenant.ts +++ b/app/javascript/actions/Tenant/submitTenant.ts @@ -49,6 +49,7 @@ export const submitTenant = ( userPassword: string, siteName: string, subdomain: string, + isOAuthLogin: boolean, authenticityToken: string, ): ThunkAction> => async (dispatch) => { dispatch(tenantSubmitStart()); @@ -67,6 +68,9 @@ export const submitTenant = ( site_name: siteName, subdomain: subdomain, }, + settings: { + is_o_auth_login: isOAuthLogin, + } }), }); const json = await res.json(); diff --git a/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx b/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx index 3074a36b..23b4cbba 100644 --- a/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx +++ b/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx @@ -3,11 +3,11 @@ import I18n from 'i18n-js'; import { IOAuth } from '../../../interfaces/IOAuth'; import Switch from '../../common/Switch'; -import Separator from '../../common/Separator'; import { AuthenticationPages } from './AuthenticationSiteSettingsP'; import CopyToClipboardButton from '../../common/CopyToClipboardButton'; import ActionLink from '../../common/ActionLink'; import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons'; +import { MutedText } from '../../common/CustomTexts'; interface Props { oAuth: IOAuth; @@ -30,52 +30,60 @@ const OAuthProviderItem = ({
{oAuth.name} -
- handleToggleEnabledOAuth(oAuth.id, !oAuth.isEnabled)} - checked={oAuth.isEnabled} - htmlId={`oAuth${oAuth.name}EnabledSwitch`} - /> -
+ { + oAuth.tenantId ? +
+ handleToggleEnabledOAuth(oAuth.id, !oAuth.isEnabled)} + checked={oAuth.isEnabled} + htmlId={`oAuth${oAuth.name}EnabledSwitch`} + /> +
+ : +
{I18n.t('site_settings.authentication.default_oauth')}
+ }
-
- - - - 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')} + +
+ } ); diff --git a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx index 348722c7..3bd55019 100644 --- a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx @@ -47,9 +47,13 @@ const TenantSignUpForm = ({ { - const res = await fetch(`/is_available?new_subdomain=${newSubdomain}`); - return res.status === HttpStatus.OK; + pattern: /^[a-zA-Z0-9-]+$/, + validate: { + noSpaces: (value) => !/\s/.test(value), + notAlreadyTaken: async (newSubdomain) => { + const res = await fetch(`/is_available?new_subdomain=${newSubdomain}`); + return res.status === HttpStatus.OK; + }, }, })} placeholder={getLabel('tenant', 'subdomain')} @@ -64,7 +68,10 @@ const TenantSignUpForm = ({ {errors.subdomain?.type === 'required' && getValidationMessage('required', 'tenant', 'subdomain')} - {errors.subdomain?.type === 'validate' && I18n.t('signup.step2.validations.subdomain_already_taken')} + {errors.subdomain?.type === 'pattern' && I18n.t('signup.step2.validations.subdomain_only_letters_and_numbers')} + + + {errors.subdomain?.type === 'notAlreadyTaken' && I18n.t('signup.step2.validations.subdomain_already_taken')} diff --git a/app/javascript/components/TenantSignUp/TenantSignUpP.tsx b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx index 3c40a6ed..1389ae6a 100644 --- a/app/javascript/components/TenantSignUp/TenantSignUpP.tsx +++ b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx @@ -5,8 +5,14 @@ import ConfirmSignUpPage from './ConfirmSignUpPage'; import TenantSignUpForm from './TenantSignUpForm'; import UserSignUpForm from './UserSignUpForm'; +import { IOAuth } from '../../interfaces/IOAuth'; interface Props { + oAuthLoginCompleted: boolean; + oauthUserEmail?: string; + oauthUserName?: string; + oAuths: Array; + isSubmitting: boolean; error: string; @@ -16,9 +22,11 @@ interface Props { userPassword: string, siteName: string, subdomain: string, + isOAuthLogin: boolean, authenticityToken: string, ): Promise; + baseUrl: string; authenticityToken: string; } @@ -35,9 +43,14 @@ export interface ITenantSignUpTenantForm { } const TenantSignUpP = ({ + oAuths, + oAuthLoginCompleted, + oauthUserEmail, + oauthUserName, isSubmitting, error, handleSubmit, + baseUrl, authenticityToken }: Props) => { const [userData, setUserData] = useState({ @@ -52,19 +65,27 @@ const TenantSignUpP = ({ subdomain: '', }); - const [currentStep, setCurrentStep] = useState(1); + const [currentStep, setCurrentStep] = useState(oAuthLoginCompleted ? 2 : 1); + const [emailAuth, setEmailAuth] = useState(false); const handleSignUpSubmit = (siteName: string, subdomain: string) => { handleSubmit( - userData.fullName, - userData.email, + oAuthLoginCompleted ? oauthUserName : userData.fullName, + oAuthLoginCompleted ? oauthUserEmail : userData.email, userData.password, siteName, subdomain, + oAuthLoginCompleted, authenticityToken, ).then(res => { if (res?.status !== HttpStatus.Created) return; + if (oAuthLoginCompleted) { + let redirectUrl = new URL(baseUrl); + redirectUrl.hostname = `${subdomain}.${redirectUrl.hostname}`; + window.location.href = `${redirectUrl.toString()}users/sign_in`; + return; + } setTenantData({ siteName, subdomain }); setCurrentStep(currentStep + 1); @@ -80,6 +101,10 @@ const TenantSignUpP = ({ setCurrentStep={setCurrentStep} emailAuth={emailAuth} setEmailAuth={setEmailAuth} + oAuths={oAuths} + oAuthLoginCompleted={oAuthLoginCompleted} + oauthUserEmail={oauthUserEmail} + oauthUserName={oauthUserName} userData={userData} setUserData={setUserData} /> diff --git a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx index 691170e4..4aff9514 100644 --- a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx @@ -4,16 +4,24 @@ 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 { DangerText } from '../common/CustomTexts'; import { getLabel, getValidationMessage } from '../../helpers/formUtils'; import { EMAIL_REGEX } from '../../constants/regex'; +import { IOAuth } from '../../interfaces/IOAuth'; +import ActionLink from '../common/ActionLink'; +import { BackIcon } from '../common/Icons'; interface Props { currentStep: number; setCurrentStep(step: number): void; emailAuth: boolean; setEmailAuth(enabled: boolean): void; + oAuths: Array; + oAuthLoginCompleted: boolean; + oauthUserEmail?: string; + oauthUserName?: string; userData: ITenantSignUpUserForm; setUserData({}: ITenantSignUpUserForm): void; } @@ -23,6 +31,10 @@ const UserSignUpForm = ({ setCurrentStep, emailAuth, setEmailAuth, + oAuths, + oAuthLoginCompleted, + oauthUserEmail, + oauthUserName, userData, setUserData, }: Props) => { @@ -49,14 +61,37 @@ const UserSignUpForm = ({ { currentStep === 1 && !emailAuth && + <> + + { + oAuths.filter(oAuth => oAuth.isEnabled).map((oAuth, i) => + + ) + } + } { currentStep === 1 && emailAuth &&
+ setEmailAuth(false)} + icon={} + customClass="backButton" + > + {I18n.t('common.buttons.back')} + +
{userData.fullName} ({userData.email})

} + + { + currentStep === 2 && oAuthLoginCompleted && +

{oauthUserName} ({oauthUserEmail})

+ } ); } diff --git a/app/javascript/components/TenantSignUp/index.tsx b/app/javascript/components/TenantSignUp/index.tsx index 11183979..a65a6f94 100644 --- a/app/javascript/components/TenantSignUp/index.tsx +++ b/app/javascript/components/TenantSignUp/index.tsx @@ -1,14 +1,18 @@ import * as React from 'react'; import { Provider } from 'react-redux'; +import { Store } from 'redux'; import createStoreHelper from '../../helpers/createStore'; - import TenantSignUp from '../../containers/TenantSignUp'; - -import { Store } from 'redux'; import { State } from '../../reducers/rootReducer'; +import { IOAuthJSON, oAuthJSON2JS } from '../../interfaces/IOAuth'; interface Props { + oAuths: Array; + oAuthLoginCompleted: boolean; + oauthUserEmail?: string; + oauthUserName?: string; + baseUrl: string; authenticityToken: string; } @@ -22,11 +26,23 @@ class TenantSignUpRoot extends React.Component { } render() { - const { authenticityToken } = this.props; + const { + oAuths, + oAuthLoginCompleted, + oauthUserEmail, + oauthUserName, + baseUrl, + authenticityToken, + } = this.props; return ( oAuthJSON2JS(oAuth))} + baseUrl={baseUrl} authenticityToken={authenticityToken} /> diff --git a/app/javascript/components/common/CustomTexts.tsx b/app/javascript/components/common/CustomTexts.tsx index 273e878e..86abaf5d 100644 --- a/app/javascript/components/common/CustomTexts.tsx +++ b/app/javascript/components/common/CustomTexts.tsx @@ -21,6 +21,10 @@ export const MutedText = ({ children }: Props) => ( {children} ); +export const CenteredText = ({ children }: Props) => ( +

{children}

+); + export const CenteredMutedText = ({ children }: Props) => (

{children}

); diff --git a/app/javascript/components/common/OAuthProviderLink.tsx b/app/javascript/components/common/OAuthProviderLink.tsx new file mode 100644 index 00000000..61c39803 --- /dev/null +++ b/app/javascript/components/common/OAuthProviderLink.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import I18n from 'i18n-js'; + +interface Props { + oAuthId: number; + oAuthName: string; + oAuthLogo?: string; + oAuthReason: string; + isSignUp?: boolean; +} + +const OAuthProviderLink = ({ oAuthId, oAuthName, oAuthLogo, oAuthReason, isSignUp = false }: Props) => ( + +); + +export default OAuthProviderLink; \ No newline at end of file diff --git a/app/javascript/containers/TenantSignUp.tsx b/app/javascript/containers/TenantSignUp.tsx index 53638bb5..4b938ed1 100644 --- a/app/javascript/containers/TenantSignUp.tsx +++ b/app/javascript/containers/TenantSignUp.tsx @@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch: any) => ({ userPassword: string, siteName: string, subdomain: string, + isOAuthLogin: boolean, authenticityToken: string, ): Promise { return dispatch(submitTenant( @@ -25,6 +26,7 @@ const mapDispatchToProps = (dispatch: any) => ({ userPassword, siteName, subdomain, + isOAuthLogin, authenticityToken, )); } diff --git a/app/javascript/interfaces/IOAuth.ts b/app/javascript/interfaces/IOAuth.ts index fe7f283d..51076e18 100644 --- a/app/javascript/interfaces/IOAuth.ts +++ b/app/javascript/interfaces/IOAuth.ts @@ -11,8 +11,9 @@ export interface IOAuth { scope: string; jsonUserEmailPath: string; jsonUserNamePath?: string; - + callbackUrl?: string; + tenantId?: number; } export interface IOAuthJSON { @@ -30,9 +31,10 @@ export interface IOAuthJSON { json_user_name_path?: string; callback_url?: string; + tenant_id?: string; } -export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON) => ({ +export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({ id: parseInt(oAuthJSON.id), name: oAuthJSON.name, logo: oAuthJSON.logo, @@ -47,6 +49,7 @@ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON) => ({ jsonUserNamePath: oAuthJSON.json_user_name_path, callbackUrl: oAuthJSON.callback_url, + tenantId: oAuthJSON.tenant_id ? parseInt(oAuthJSON.tenant_id) : null, }); export const oAuthJS2JSON = (oAuth: IOAuth) => ({ @@ -64,4 +67,5 @@ export const oAuthJS2JSON = (oAuth: IOAuth) => ({ json_user_name_path: oAuth.jsonUserNamePath, callback_url: oAuth.callbackUrl, + tenant_id: oAuth.tenantId, }); \ No newline at end of file diff --git a/app/models/o_auth.rb b/app/models/o_auth.rb index 7eb5b401..1804f55c 100644 --- a/app/models/o_auth.rb +++ b/app/models/o_auth.rb @@ -3,6 +3,8 @@ class OAuth < ApplicationRecord include ApplicationHelper include Rails.application.routes.url_helpers + scope :include_defaults, -> { unscope(where: :tenant_id).where(tenant_id: Current.tenant).or(unscope(where: :tenant_id).where(tenant_id: nil, is_enabled: true)) } + attr_accessor :state validates :name, presence: true, uniqueness: { scope: :tenant_id } @@ -15,8 +17,20 @@ class OAuth < ApplicationRecord validates :scope, presence: true validates :json_user_email_path, presence: true + def is_default? + tenant_id == nil + end + def callback_url - add_subdomain_to(method(:o_auth_callback_url), id) + # Default OAuths are available to all tenants + # but must have a single callback url: + # for this reason, we don't preprend tenant subdomain + # but rather use the "login" subdomain + if self.is_default? + o_auth_callback_url(id, host: Rails.application.base_url, subdomain: "login") + else + add_subdomain_to(method(:o_auth_callback_url), id) + end end def authorize_url_with_query_params diff --git a/app/models/tenant.rb b/app/models/tenant.rb index ff8ffe28..19b72a79 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -9,6 +9,7 @@ class Tenant < ApplicationRecord enum status: [:active, :pending, :blocked] after_initialize :set_default_status, if: :new_record? + before_save :downcase_subdomain validates :site_name, presence: true validates :subdomain, presence: true, uniqueness: true @@ -18,4 +19,8 @@ class Tenant < ApplicationRecord def set_default_status self.status ||= :pending end + + def downcase_subdomain + self.subdomain = self.subdomain.downcase + end end diff --git a/app/models/user.rb b/app/models/user.rb index b8085a6a..af4ef642 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,8 +16,6 @@ class User < ApplicationRecord after_initialize :set_default_role, if: :new_record? after_initialize :set_default_status, if: :new_record? - before_save :skip_confirmation - validates :full_name, presence: true, length: { in: 2..32 } validates :email, presence: true, @@ -54,7 +52,6 @@ class User < ApplicationRecord end def skip_confirmation - return if Rails.application.email_confirmation? skip_confirmation! skip_confirmation_notification! skip_reconfirmation! @@ -84,4 +81,15 @@ class User < ApplicationRecord def blocked? status == 'blocked' end + + def generate_oauth_token + self.oauth_token = SecureRandom.urlsafe_base64 + self.save! + oauth_token + end + + def invalidate_oauth_token + self.oauth_token = nil + self.save! + end end diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index e311e8a6..6b47543a 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -1,55 +1,49 @@ -<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> -

<%= t('common.forms.auth.sign_up') %>

+
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "new_user_form" }) do |f| %> +

<%= t('common.forms.auth.sign_up') %>

- <%= render "devise/shared/error_messages", resource: resource %> + <%= render "devise/shared/error_messages", resource: resource %> -
- <%= f.label :full_name, class: "sr-only" %> - <%= f.text_field :full_name, - autofocus: true, - placeholder: t('common.forms.auth.full_name'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :full_name, class: "sr-only" %> + <%= f.text_field :full_name, + autofocus: true, + placeholder: t('common.forms.auth.full_name'), + required: true, + class: "form-control" %> +
-
- <%= f.label :email, class: "sr-only" %> - <%= f.email_field :email, - autocomplete: "email", - placeholder: t('common.forms.auth.email'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :email, class: "sr-only" %> + <%= f.email_field :email, + autocomplete: "email", + placeholder: t('common.forms.auth.email'), + required: true, + class: "form-control" %> +
-
- <%= f.label :password, class: "sr-only" %> - <%= f.password_field :password, - placeholder: t('common.forms.auth.password'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :password, class: "sr-only" %> + <%= f.password_field :password, + placeholder: t('common.forms.auth.password'), + required: true, + class: "form-control" %> +
-
- <%= f.label :password_confirmation, class: "sr-only" %> - <%= f.password_field :password_confirmation, - placeholder: t('common.forms.auth.password_confirmation'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :password_confirmation, class: "sr-only" %> + <%= f.password_field :password_confirmation, + placeholder: t('common.forms.auth.password_confirmation'), + required: true, + class: "form-control" %> +
-
- <%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %> -
- - <% if not @o_auths.blank? %> -
- <% @o_auths.each do |o_auth| %> -

- <%= link_to t('common.forms.auth.sign_up_with', o_auth: o_auth.name), - o_auth_start_path(o_auth, reason: 'user') %> -

- <% end %> +
+ <%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %> +
<% end %> -<% end %> + + <%= render "devise/shared/o_auths", is_sign_up: true %> +
<%= render "devise/shared/links" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index d345aac4..2702c29d 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,44 +1,38 @@ -<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> -

<%= t('common.forms.auth.log_in') %>

+
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "new_user_form" }) do |f| %> +

<%= t('common.forms.auth.log_in') %>

-
- <%= f.label :email, class: "sr-only" %> - <%= f.email_field :email, - autocomplete: "email", - placeholder: t('common.forms.auth.email'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :email, class: "sr-only" %> + <%= f.email_field :email, + autocomplete: "email", + placeholder: t('common.forms.auth.email'), + required: true, + class: "form-control" %> +
-
- <%= f.label :password, class: "sr-only" %> - <%= f.password_field :password, - autocomplete: "current-password", - placeholder: t('common.forms.auth.password'), - required: true, - class: "form-control" %> -
+
+ <%= f.label :password, class: "sr-only" %> + <%= f.password_field :password, + autocomplete: "current-password", + placeholder: t('common.forms.auth.password'), + required: true, + class: "form-control" %> +
- <% if devise_mapping.rememberable? %> -
- <%= f.check_box :remember_me, class: "form-check-input" %> - <%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %> + <% if devise_mapping.rememberable? %> +
+ <%= f.check_box :remember_me, class: "form-check-input" %> + <%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %> +
+ <% end %> + +
+ <%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %>
<% end %> -
- <%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %> -
- - <% if not @o_auths.empty? %> -
- <% @o_auths.each do |o_auth| %> -

- <%= link_to t('common.forms.auth.log_in_with', o_auth: o_auth.name), - o_auth_start_path(o_auth, reason: 'user') %> -

- <% end %> - <% end %> -<% end %> + <%= render "devise/shared/o_auths" %> +
<%= render "devise/shared/links" %> \ No newline at end of file diff --git a/app/views/devise/shared/_o_auths.html.erb b/app/views/devise/shared/_o_auths.html.erb new file mode 100644 index 00000000..b5ebb531 --- /dev/null +++ b/app/views/devise/shared/_o_auths.html.erb @@ -0,0 +1,17 @@ +<% if not @o_auths.blank? %> +
+ <% @o_auths.each do |o_auth| %> + <%= + react_component( + 'common/OAuthProviderLink', + { + oAuthId: o_auth.id, + oAuthName: o_auth.name, + oAuthLogo: o_auth.logo, + oAuthReason: "login", + isSignUp: defined?(is_sign_up) ? is_sign_up : false, + } + ) + %> + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/tenants/new.html.erb b/app/views/tenants/new.html.erb index c0f40739..837e0617 100644 --- a/app/views/tenants/new.html.erb +++ b/app/views/tenants/new.html.erb @@ -2,6 +2,11 @@ react_component( 'TenantSignUp', { + oAuths: @o_auths || [], + oAuthLoginCompleted: @o_auth_login_completed || false, + oauthUserEmail: @user_email, + oauthUserName: @user_name, + baseUrl: Rails.application.base_url, authenticityToken: form_authenticity_token } ) diff --git a/app/workflows/OAuthSignInUser.rb b/app/workflows/OAuthSignInUser.rb index 3924f457..bcbe2e66 100644 --- a/app/workflows/OAuthSignInUser.rb +++ b/app/workflows/OAuthSignInUser.rb @@ -23,11 +23,11 @@ class OAuthSignInUser def run return nil unless @o_auth and @o_auth.class == OAuth and @o_auth.is_enabled? - return nil unless @user_profile and @user_profile.class == Hash + return nil unless @user_profile and (@user_profile.class == Hash or @user_profile.class == Array) begin # Attempts to get email from user_profile Hash - email = query_path_from_hash(@user_profile, @o_auth.json_user_email_path) + email = query_path_from_object(@user_profile, @o_auth.json_user_email_path) return nil if email.nil? or not URI::MailTo::EMAIL_REGEXP.match?(email) @@ -36,7 +36,7 @@ class OAuthSignInUser if user.nil? if not @o_auth.json_user_name_path.blank? - full_name = query_path_from_hash(@user_profile, @o_auth.json_user_name_path) + full_name = query_path_from_object(@user_profile, @o_auth.json_user_name_path) end full_name ||= I18n.t('defaults.user_full_name') @@ -46,7 +46,7 @@ class OAuthSignInUser password: Devise.friendly_token, status: 'active' ) - user.skip_confirmation! + user.skip_confirmation user.save end diff --git a/config/application.rb b/config/application.rb index 2aaeb723..927f9a54 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,10 +24,6 @@ module App ENV["MULTI_TENANCY"] == "true" end - def email_confirmation? - ENV["EMAIL_CONFIRMATION"] == "true" - end - def posts_per_page 15 end diff --git a/config/environments/production.rb b/config/environments/production.rb index 7b6b2377..ba33a0c2 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -81,12 +81,10 @@ Rails.application.configure do } end - if ENV['EMAIL_CONFIRMATION'] - config.action_mailer.default_options = { - from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto "), + config.action_mailer.default_options = { + from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto "), reply_to: ENV.fetch("EMAIL_MAIL_REPLY_TO", ENV.fetch("EMAIL_MAIL_FROM", "Astuto ")) - } - end + } # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). diff --git a/config/environments/test.rb b/config/environments/test.rb index 274c6324..e09a63b3 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -3,9 +3,6 @@ # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! -# Set up default environment variables -ENV["EMAIL_CONFIRMATION"] = "no" - Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. # For Devise diff --git a/config/locales/en.yml b/config/locales/en.yml index 75f44188..c1f4d4b0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -69,12 +69,13 @@ en: page_title: 'Create your feedback space' step1: title: '1. Create user account' - email_auth: 'Register with email' + email_auth: 'Sign up with email' step2: title: '2. Create feedback space' create_button: 'Create feedback space' validations: subdomain_already_taken: 'Sorry, this subdomain is not available' + subdomain_only_letters_and_numbers: 'Subdomain can only contain alphanumeric characters and hyphen' step3: title: "You're almost done!" message: "Check your email %{email} to activate your new feedback space %{subdomain}!" @@ -195,6 +196,7 @@ en: authentication: title: 'Authentication' oauth_subtitle: 'OAuth providers' + default_oauth: 'Default OAuth provider' copy_url: 'Copy URL' test_page: title: '%{name} OAuth test results' diff --git a/config/routes.rb b/config/routes.rb index 120f4477..aeb368a0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ Rails.application.routes.draw do resources :o_auths, only: [:index, :create, :update, :destroy] get '/o_auths/:id/start', to: 'o_auths#start', as: :o_auth_start get '/o_auths/:id/callback', to: 'o_auths#callback', as: :o_auth_callback + get '/o_auths/sign_in_from_oauth_token', to: 'o_auths#sign_in_from_oauth_token', as: :o_auth_sign_in_from_oauth_token resources :posts, only: [:index, :create, :show, :update, :destroy] do resource :follows, only: [:create, :destroy] diff --git a/db/migrate/20240116124945_remove_not_null_constraint_to_o_auths_tenant.rb b/db/migrate/20240116124945_remove_not_null_constraint_to_o_auths_tenant.rb new file mode 100644 index 00000000..48f16174 --- /dev/null +++ b/db/migrate/20240116124945_remove_not_null_constraint_to_o_auths_tenant.rb @@ -0,0 +1,5 @@ +class RemoveNotNullConstraintToOAuthsTenant < ActiveRecord::Migration[6.1] + def change + change_column_null :o_auths, :tenant_id, true + end +end diff --git a/db/migrate/20240117112502_add_oauth_token_to_users.rb b/db/migrate/20240117112502_add_oauth_token_to_users.rb new file mode 100644 index 00000000..bfea2604 --- /dev/null +++ b/db/migrate/20240117112502_add_oauth_token_to_users.rb @@ -0,0 +1,5 @@ +class AddOauthTokenToUsers < ActiveRecord::Migration[6.1] + def change + add_column :users, :oauth_token, :string, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2415f1d9..723c5ac9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_02_11_095500) do +ActiveRecord::Schema.define(version: 2024_01_17_112502) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -77,7 +77,7 @@ ActiveRecord::Schema.define(version: 2023_02_11_095500) do t.string "scope", null: false t.string "json_user_name_path" t.string "json_user_email_path", null: false - t.bigint "tenant_id", null: false + t.bigint "tenant_id" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["name", "tenant_id"], name: "index_o_auths_on_name_and_tenant_id", unique: true @@ -165,6 +165,7 @@ ActiveRecord::Schema.define(version: 2023_02_11_095500) do t.boolean "notifications_enabled", default: true, null: false t.integer "status" t.bigint "tenant_id", null: false + t.string "oauth_token" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email", "tenant_id"], name: "index_users_on_email_and_tenant_id", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true diff --git a/spec/helpers/o_auths_helper_spec.rb b/spec/helpers/o_auths_helper_spec.rb index 44368d0c..a27c7b10 100644 --- a/spec/helpers/o_auths_helper_spec.rb +++ b/spec/helpers/o_auths_helper_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe OAuthsHelper, type: :helper do - context 'query_path_from_hash method' do + context 'query_path_from_object method' do it 'queries a path from hash' do email = "admin@example.com" name = "Admin" @@ -23,14 +23,40 @@ RSpec.describe OAuthsHelper, type: :helper do name_path = "info.name" surname_path = "info.additional_info.surnames[2][0].surname" - expect(helper.query_path_from_hash(hash, name_path)).to eq(name) - expect(helper.query_path_from_hash(hash, email_path)).to eq(email) - expect(helper.query_path_from_hash(hash, surname_path)).to eq(surname) + expect(helper.query_path_from_object(hash, name_path)).to eq(name) + expect(helper.query_path_from_object(hash, email_path)).to eq(email) + expect(helper.query_path_from_object(hash, surname_path)).to eq(surname) + end + + it 'queries a path from array' do + email1 = "admin1@example.com" + email2 = "admin2@example.com" + address = "Address1" + + array = [ + { + "email" => email1, + }, + { + "email" => email2, + "additional_info" => { + "addresses" => [{ "name" => address }] + } + } + ] + + email1_path = "[0].email" + email2_path = "[1].email" + address_path = "[1].additional_info.addresses[0].name" + + expect(helper.query_path_from_object(array, email1_path)).to eq(email1) + expect(helper.query_path_from_object(array, email2_path)).to eq(email2) + expect(helper.query_path_from_object(array, address_path)).to eq(address) end it 'returns nil if inputs are not of type Hash and String respectively' do - expect(helper.query_path_from_hash({"valid" => true}, ["invalid"])).to eq(nil) - expect(helper.query_path_from_hash("invalid", "valid")).to eq(nil) + expect(helper.query_path_from_object({"valid" => true}, ["invalid"])).to eq(nil) + expect(helper.query_path_from_object("invalid", "valid")).to eq(nil) end it 'returns nil if path not found' do @@ -44,10 +70,10 @@ RSpec.describe OAuthsHelper, type: :helper do } name_path = "name" - expect(helper.query_path_from_hash(hash, name_path)).to eq(nil) + expect(helper.query_path_from_object(hash, name_path)).to eq(nil) name_path = "info.names[0]" - expect(helper.query_path_from_hash(hash, name_path)).to eq(nil) + expect(helper.query_path_from_object(hash, name_path)).to eq(nil) end end end \ No newline at end of file