Add default OAuths (#259)

This commit is contained in:
Riccardo Graziosi
2024-01-22 14:45:48 +01:00
committed by GitHub
parent 0828c9c879
commit 653e139a9e
32 changed files with 512 additions and 213 deletions

View File

@@ -208,4 +208,19 @@
text-decoration: none !important; text-decoration: none !important;
cursor: not-allowed; cursor: not-allowed;
} }
}
.oauthProviderBtn {
@extend
.btn,
.btn-block,
.btn-outline-dark,
.mt-2,
.mb-2;
.oauthProviderText {
@extend .ml-2;
vertical-align: middle;
}
} }

View File

@@ -49,7 +49,8 @@ class ApplicationController < ActionController::Base
end end
def load_oauths def load_oauths
@o_auths = Current.tenant_or_raise!.o_auths @o_auths = OAuth
.include_defaults
.where(is_enabled: true) .where(is_enabled: true)
.order(created_at: :asc) .order(created_at: :asc)
end end

View File

@@ -7,15 +7,17 @@ class OAuthsController < ApplicationController
TOKEN_STATE_SEPARATOR = '-' 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 # Generates authorize url with required parameters and redirects to provider
def start def start
@o_auth = OAuth.find(params[:id]) @o_auth = OAuth.unscoped.include_defaults.find(params[:id])
return if params[:reason] == 'user' and not @o_auth.is_enabled?
return if params[:reason] != 'test' and not @o_auth.is_enabled?
# Generate random state + other query params # Generate random state + other query params
token_state = "#{params[:reason]}#{TOKEN_STATE_SEPARATOR}#{Devise.friendly_token(30)}" tenant_domain = Current.tenant ? Current.tenant_or_raise!.subdomain : "null"
session[:token_state] = token_state 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 @o_auth.state = token_state
redirect_to @o_auth.authorize_url_with_query_params redirect_to @o_auth.authorize_url_with_query_params
@@ -24,32 +26,42 @@ class OAuthsController < ApplicationController
# [subdomain.]base_url/o_auths/:id/callback # [subdomain.]base_url/o_auths/:id/callback
# Exchange authorization code for access token, fetch user info and sign in/up # Exchange authorization code for access token, fetch user info and sign in/up
def callback 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( user_profile = OAuthExchangeAuthCodeForProfile.new(
authorization_code: params[:code], authorization_code: params[:code],
o_auth: @o_auth o_auth: @o_auth
).run ).run
if reason == 'user' if reason == 'login'
user = OAuthSignInUser.new( user = OAuthSignInUser.new(
user_profile: user_profile, user_profile: user_profile,
o_auth: @o_auth o_auth: @o_auth
).run ).run
if user if user
sign_in user oauth_token = user.generate_oauth_token
flash[:notice] = I18n.t('devise.sessions.signed_in') redirect_to add_subdomain_to(method(:o_auth_sign_in_from_oauth_token_url), nil, {user_id: user.id, token: oauth_token})
redirect_to root_path
else else
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name) 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 end
elsif reason == 'test' elsif reason == 'test'
unless user_signed_in? and current_user.admin? unless user_signed_in? and current_user.admin?
flash[:alert] = I18n.t('errors.unauthorized') flash[:alert] = I18n.t('errors.unauthorized')
redirect_to root_url redirect_to root_url
@@ -57,15 +69,53 @@ class OAuthsController < ApplicationController
end end
@user_profile = user_profile @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) @email_valid = URI::MailTo::EMAIL_REGEXP.match?(@user_email)
@user_name = query_path_from_hash(user_profile, @o_auth.json_user_name_path) if not @o_auth.json_user_name_path.blank?
@name_valid = !@user_name.nil? @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 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 else
flash[:alert] = I18n.t('errors.unknown') flash[:alert] = I18n.t('errors.unknown')
redirect_to root_url redirect_to root_url
end
end
# [subdomain.]base_url/o_auths/sign_in_from_oauth_token?user_id=<id>&token=<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
end end
@@ -74,7 +124,7 @@ class OAuthsController < ApplicationController
def index def index
authorize OAuth 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) render json: to_json_custom(@o_auths)
end end

View File

@@ -5,6 +5,7 @@ class TenantsController < ApplicationController
def new def new
@page_title = t('signup.page_title') @page_title = t('signup.page_title')
@o_auths = OAuth.unscoped.where(tenant_id: nil)
end end
def show def show
@@ -16,17 +17,34 @@ class TenantsController < ApplicationController
@tenant.assign_attributes(tenant_create_params) @tenant.assign_attributes(tenant_create_params)
authorize @tenant authorize @tenant
is_o_auth_login = params[:settings][:is_o_auth_login]
ActiveRecord::Base.transaction do 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! @tenant.save!
Current.tenant = @tenant Current.tenant = @tenant
@user = User.create!( @user = User.new(
full_name: params[:user][:full_name], full_name: params[:user][:full_name] || I18n.t('defaults.user_full_name'),
email: params[:user][:email], email: params[:user][:email],
password: params[:user][:password], password: is_o_auth_login ? Devise.friendly_token : params[:user][:password],
role: "owner" role: "owner"
) )
if is_o_auth_login
@user.skip_confirmation
end
@user.save!
render json: @tenant, status: :created render json: @tenant, status: :created
rescue ActiveRecord::RecordInvalid => exception rescue ActiveRecord::RecordInvalid => exception

View File

@@ -1,26 +1,34 @@
module OAuthsHelper module OAuthsHelper
def query_path_from_hash(hash, path) # Retrieves a value from a hash/array using a given path.
return nil unless hash.class == Hash and path.class == String # 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.
path_array = path # returns [Object, nil] The value at the specified path, or nil if the path is invalid or the value is not found.
.split(Regexp.union([ '.', '[', ']' ])) # split by . and [] def query_path_from_object(obj, path)
.filter { |v| not v.blank? } # remove possible blank values return nil unless obj.class == Hash or obj.class == Array
.map do |v| # convert indexes to integer return nil unless path.class == String
if v == "0" begin
0 path_array = path
elsif v.to_i == 0 .split(Regexp.union([ '.', '[', ']' ])) # split by . and []
v .filter { |v| not v.blank? } # remove possible blank values
else .map do |v| # convert indexes to integer
v.to_i if v == "0"
0
elsif v.to_i == 0
v
else
v.to_i
end
end end
path_array.each do |selector|
break if obj == nil
obj = obj[selector]
end end
path_array.each do |selector|
break if hash == nil
hash = hash[selector] obj
rescue
nil
end end
hash
end end
end end

View File

@@ -49,6 +49,7 @@ export const submitTenant = (
userPassword: string, userPassword: string,
siteName: string, siteName: string,
subdomain: string, subdomain: string,
isOAuthLogin: boolean,
authenticityToken: string, authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => { ): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(tenantSubmitStart()); dispatch(tenantSubmitStart());
@@ -67,6 +68,9 @@ export const submitTenant = (
site_name: siteName, site_name: siteName,
subdomain: subdomain, subdomain: subdomain,
}, },
settings: {
is_o_auth_login: isOAuthLogin,
}
}), }),
}); });
const json = await res.json(); const json = await res.json();

View File

@@ -3,11 +3,11 @@ import I18n from 'i18n-js';
import { IOAuth } from '../../../interfaces/IOAuth'; import { IOAuth } from '../../../interfaces/IOAuth';
import Switch from '../../common/Switch'; import Switch from '../../common/Switch';
import Separator from '../../common/Separator';
import { AuthenticationPages } from './AuthenticationSiteSettingsP'; import { AuthenticationPages } from './AuthenticationSiteSettingsP';
import CopyToClipboardButton from '../../common/CopyToClipboardButton'; import CopyToClipboardButton from '../../common/CopyToClipboardButton';
import ActionLink from '../../common/ActionLink'; import ActionLink from '../../common/ActionLink';
import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons'; import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons';
import { MutedText } from '../../common/CustomTexts';
interface Props { interface Props {
oAuth: IOAuth; oAuth: IOAuth;
@@ -30,52 +30,60 @@ const OAuthProviderItem = ({
<div className="oAuthNameAndEnabled"> <div className="oAuthNameAndEnabled">
<span className="oAuthName">{oAuth.name}</span> <span className="oAuthName">{oAuth.name}</span>
<div className="oAuthIsEnabled"> {
<Switch oAuth.tenantId ?
label={I18n.t(`common.${oAuth.isEnabled ? 'enabled' : 'disabled'}`)} <div className="oAuthIsEnabled">
onClick={() => handleToggleEnabledOAuth(oAuth.id, !oAuth.isEnabled)} <Switch
checked={oAuth.isEnabled} label={I18n.t(`common.${oAuth.isEnabled ? 'enabled' : 'disabled'}`)}
htmlId={`oAuth${oAuth.name}EnabledSwitch`} onClick={() => handleToggleEnabledOAuth(oAuth.id, !oAuth.isEnabled)}
/> checked={oAuth.isEnabled}
</div> htmlId={`oAuth${oAuth.name}EnabledSwitch`}
/>
</div>
:
<div><MutedText>{I18n.t('site_settings.authentication.default_oauth')}</MutedText></div>
}
</div> </div>
</div> </div>
<div className="oAuthActions"> {
<CopyToClipboardButton oAuth.tenantId &&
label={I18n.t('site_settings.authentication.copy_url')} <div className="oAuthActions">
textToCopy={oAuth.callbackUrl} <CopyToClipboardButton
/> label={I18n.t('site_settings.authentication.copy_url')}
textToCopy={oAuth.callbackUrl}
<ActionLink />
onClick={() =>
window.open(`/o_auths/${oAuth.id}/start?reason=test`, '', 'width=640, height=640') <ActionLink
} onClick={() =>
icon={<TestIcon />} window.open(`/o_auths/${oAuth.id}/start?reason=test`, '', 'width=640, height=640')
customClass='testAction' }
> icon={<TestIcon />}
{I18n.t('common.buttons.test')} customClass='testAction'
</ActionLink> >
{I18n.t('common.buttons.test')}
<ActionLink </ActionLink>
onClick={() => {
setSelectedOAuth(oAuth.id); <ActionLink
setPage('edit'); onClick={() => {
}} setSelectedOAuth(oAuth.id);
icon={<EditIcon />} setPage('edit');
customClass='editAction' }}
> icon={<EditIcon />}
{I18n.t('common.buttons.edit')} customClass='editAction'
</ActionLink> >
{I18n.t('common.buttons.edit')}
<ActionLink </ActionLink>
onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteOAuth(oAuth.id)}
icon={<DeleteIcon />} <ActionLink
customClass='deleteAction' onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteOAuth(oAuth.id)}
> icon={<DeleteIcon />}
{I18n.t('common.buttons.delete')} customClass='deleteAction'
</ActionLink> >
</div> {I18n.t('common.buttons.delete')}
</ActionLink>
</div>
}
</li> </li>
); );

View File

@@ -47,9 +47,13 @@ const TenantSignUpForm = ({
<input <input
{...register('subdomain', { {...register('subdomain', {
required: true, required: true,
validate: async (newSubdomain) => { pattern: /^[a-zA-Z0-9-]+$/,
const res = await fetch(`/is_available?new_subdomain=${newSubdomain}`); validate: {
return res.status === HttpStatus.OK; 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')} placeholder={getLabel('tenant', 'subdomain')}
@@ -64,7 +68,10 @@ const TenantSignUpForm = ({
{errors.subdomain?.type === 'required' && getValidationMessage('required', 'tenant', 'subdomain')} {errors.subdomain?.type === 'required' && getValidationMessage('required', 'tenant', 'subdomain')}
</DangerText> </DangerText>
<DangerText> <DangerText>
{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')}
</DangerText>
<DangerText>
{errors.subdomain?.type === 'notAlreadyTaken' && I18n.t('signup.step2.validations.subdomain_already_taken')}
</DangerText> </DangerText>
</div> </div>

View File

@@ -5,8 +5,14 @@ import ConfirmSignUpPage from './ConfirmSignUpPage';
import TenantSignUpForm from './TenantSignUpForm'; import TenantSignUpForm from './TenantSignUpForm';
import UserSignUpForm from './UserSignUpForm'; import UserSignUpForm from './UserSignUpForm';
import { IOAuth } from '../../interfaces/IOAuth';
interface Props { interface Props {
oAuthLoginCompleted: boolean;
oauthUserEmail?: string;
oauthUserName?: string;
oAuths: Array<IOAuth>;
isSubmitting: boolean; isSubmitting: boolean;
error: string; error: string;
@@ -16,9 +22,11 @@ interface Props {
userPassword: string, userPassword: string,
siteName: string, siteName: string,
subdomain: string, subdomain: string,
isOAuthLogin: boolean,
authenticityToken: string, authenticityToken: string,
): Promise<any>; ): Promise<any>;
baseUrl: string;
authenticityToken: string; authenticityToken: string;
} }
@@ -35,9 +43,14 @@ export interface ITenantSignUpTenantForm {
} }
const TenantSignUpP = ({ const TenantSignUpP = ({
oAuths,
oAuthLoginCompleted,
oauthUserEmail,
oauthUserName,
isSubmitting, isSubmitting,
error, error,
handleSubmit, handleSubmit,
baseUrl,
authenticityToken authenticityToken
}: Props) => { }: Props) => {
const [userData, setUserData] = useState({ const [userData, setUserData] = useState({
@@ -52,19 +65,27 @@ const TenantSignUpP = ({
subdomain: '', subdomain: '',
}); });
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(oAuthLoginCompleted ? 2 : 1);
const [emailAuth, setEmailAuth] = useState(false); const [emailAuth, setEmailAuth] = useState(false);
const handleSignUpSubmit = (siteName: string, subdomain: string) => { const handleSignUpSubmit = (siteName: string, subdomain: string) => {
handleSubmit( handleSubmit(
userData.fullName, oAuthLoginCompleted ? oauthUserName : userData.fullName,
userData.email, oAuthLoginCompleted ? oauthUserEmail : userData.email,
userData.password, userData.password,
siteName, siteName,
subdomain, subdomain,
oAuthLoginCompleted,
authenticityToken, authenticityToken,
).then(res => { ).then(res => {
if (res?.status !== HttpStatus.Created) return; 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 }); setTenantData({ siteName, subdomain });
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
@@ -80,6 +101,10 @@ const TenantSignUpP = ({
setCurrentStep={setCurrentStep} setCurrentStep={setCurrentStep}
emailAuth={emailAuth} emailAuth={emailAuth}
setEmailAuth={setEmailAuth} setEmailAuth={setEmailAuth}
oAuths={oAuths}
oAuthLoginCompleted={oAuthLoginCompleted}
oauthUserEmail={oauthUserEmail}
oauthUserName={oauthUserName}
userData={userData} userData={userData}
setUserData={setUserData} setUserData={setUserData}
/> />

View File

@@ -4,16 +4,24 @@ import I18n from 'i18n-js';
import Box from '../common/Box'; import Box from '../common/Box';
import Button from '../common/Button'; import Button from '../common/Button';
import OAuthProviderLink from '../common/OAuthProviderLink';
import { ITenantSignUpUserForm } from './TenantSignUpP'; import { ITenantSignUpUserForm } from './TenantSignUpP';
import { DangerText } from '../common/CustomTexts'; import { DangerText } from '../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../helpers/formUtils'; import { getLabel, getValidationMessage } from '../../helpers/formUtils';
import { EMAIL_REGEX } from '../../constants/regex'; import { EMAIL_REGEX } from '../../constants/regex';
import { IOAuth } from '../../interfaces/IOAuth';
import ActionLink from '../common/ActionLink';
import { BackIcon } from '../common/Icons';
interface Props { interface Props {
currentStep: number; currentStep: number;
setCurrentStep(step: number): void; setCurrentStep(step: number): void;
emailAuth: boolean; emailAuth: boolean;
setEmailAuth(enabled: boolean): void; setEmailAuth(enabled: boolean): void;
oAuths: Array<IOAuth>;
oAuthLoginCompleted: boolean;
oauthUserEmail?: string;
oauthUserName?: string;
userData: ITenantSignUpUserForm; userData: ITenantSignUpUserForm;
setUserData({}: ITenantSignUpUserForm): void; setUserData({}: ITenantSignUpUserForm): void;
} }
@@ -23,6 +31,10 @@ const UserSignUpForm = ({
setCurrentStep, setCurrentStep,
emailAuth, emailAuth,
setEmailAuth, setEmailAuth,
oAuths,
oAuthLoginCompleted,
oauthUserEmail,
oauthUserName,
userData, userData,
setUserData, setUserData,
}: Props) => { }: Props) => {
@@ -49,14 +61,37 @@ const UserSignUpForm = ({
{ {
currentStep === 1 && !emailAuth && currentStep === 1 && !emailAuth &&
<>
<Button className="emailAuth" onClick={() => setEmailAuth(true)}> <Button className="emailAuth" onClick={() => setEmailAuth(true)}>
{ I18n.t('signup.step1.email_auth') } { I18n.t('signup.step1.email_auth') }
</Button> </Button>
{
oAuths.filter(oAuth => oAuth.isEnabled).map((oAuth, i) =>
<OAuthProviderLink
oAuthId={oAuth.id}
oAuthName={oAuth.name}
oAuthLogo={oAuth.logo}
oAuthReason='tenantsignup'
isSignUp
key={i}
/>
)
}
</>
} }
{ {
currentStep === 1 && emailAuth && currentStep === 1 && emailAuth &&
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<ActionLink
onClick={() => setEmailAuth(false)}
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<div className="formRow"> <div className="formRow">
<input <input
{...register('fullName', { required: true, minLength: 2 })} {...register('fullName', { required: true, minLength: 2 })}
@@ -116,9 +151,14 @@ const UserSignUpForm = ({
} }
{ {
currentStep === 2 && currentStep === 2 && !oAuthLoginCompleted &&
<p><b>{userData.fullName}</b> ({userData.email})</p> <p><b>{userData.fullName}</b> ({userData.email})</p>
} }
{
currentStep === 2 && oAuthLoginCompleted &&
<p><b>{oauthUserName}</b> ({oauthUserEmail})</p>
}
</Box> </Box>
); );
} }

View File

@@ -1,14 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { Store } from 'redux';
import createStoreHelper from '../../helpers/createStore'; import createStoreHelper from '../../helpers/createStore';
import TenantSignUp from '../../containers/TenantSignUp'; import TenantSignUp from '../../containers/TenantSignUp';
import { Store } from 'redux';
import { State } from '../../reducers/rootReducer'; import { State } from '../../reducers/rootReducer';
import { IOAuthJSON, oAuthJSON2JS } from '../../interfaces/IOAuth';
interface Props { interface Props {
oAuths: Array<IOAuthJSON>;
oAuthLoginCompleted: boolean;
oauthUserEmail?: string;
oauthUserName?: string;
baseUrl: string;
authenticityToken: string; authenticityToken: string;
} }
@@ -22,11 +26,23 @@ class TenantSignUpRoot extends React.Component<Props> {
} }
render() { render() {
const { authenticityToken } = this.props; const {
oAuths,
oAuthLoginCompleted,
oauthUserEmail,
oauthUserName,
baseUrl,
authenticityToken,
} = this.props;
return ( return (
<Provider store={this.store}> <Provider store={this.store}>
<TenantSignUp <TenantSignUp
oAuthLoginCompleted={oAuthLoginCompleted}
oauthUserEmail={oauthUserEmail}
oauthUserName={oauthUserName}
oAuths={oAuths.map(oAuth => oAuthJSON2JS(oAuth))}
baseUrl={baseUrl}
authenticityToken={authenticityToken} authenticityToken={authenticityToken}
/> />
</Provider> </Provider>

View File

@@ -21,6 +21,10 @@ export const MutedText = ({ children }: Props) => (
<span className="mutedText">{children}</span> <span className="mutedText">{children}</span>
); );
export const CenteredText = ({ children }: Props) => (
<p className="centeredText">{children}</p>
);
export const CenteredMutedText = ({ children }: Props) => ( export const CenteredMutedText = ({ children }: Props) => (
<p className="centeredText"><span className="mutedText">{children}</span></p> <p className="centeredText"><span className="mutedText">{children}</span></p>
); );

View File

@@ -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) => (
<button
onClick={() => window.location.href = `/o_auths/${oAuthId}/start?reason=${oAuthReason}`}
className={`oauthProviderBtn oauthProvider${oAuthName.replace(' ', '')}`}
>
<img src={oAuthLogo} alt={oAuthName} width={28} height={28} />
<span className='oauthProviderText'>
{
isSignUp ?
I18n.t('common.forms.auth.sign_up_with', { o_auth: oAuthName })
:
I18n.t('common.forms.auth.log_in_with', { o_auth: oAuthName })
}
</span>
</button>
);
export default OAuthProviderLink;

View File

@@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch: any) => ({
userPassword: string, userPassword: string,
siteName: string, siteName: string,
subdomain: string, subdomain: string,
isOAuthLogin: boolean,
authenticityToken: string, authenticityToken: string,
): Promise<any> { ): Promise<any> {
return dispatch(submitTenant( return dispatch(submitTenant(
@@ -25,6 +26,7 @@ const mapDispatchToProps = (dispatch: any) => ({
userPassword, userPassword,
siteName, siteName,
subdomain, subdomain,
isOAuthLogin,
authenticityToken, authenticityToken,
)); ));
} }

View File

@@ -11,8 +11,9 @@ export interface IOAuth {
scope: string; scope: string;
jsonUserEmailPath: string; jsonUserEmailPath: string;
jsonUserNamePath?: string; jsonUserNamePath?: string;
callbackUrl?: string; callbackUrl?: string;
tenantId?: number;
} }
export interface IOAuthJSON { export interface IOAuthJSON {
@@ -30,9 +31,10 @@ export interface IOAuthJSON {
json_user_name_path?: string; json_user_name_path?: string;
callback_url?: string; callback_url?: string;
tenant_id?: string;
} }
export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON) => ({ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON): IOAuth => ({
id: parseInt(oAuthJSON.id), id: parseInt(oAuthJSON.id),
name: oAuthJSON.name, name: oAuthJSON.name,
logo: oAuthJSON.logo, logo: oAuthJSON.logo,
@@ -47,6 +49,7 @@ export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON) => ({
jsonUserNamePath: oAuthJSON.json_user_name_path, jsonUserNamePath: oAuthJSON.json_user_name_path,
callbackUrl: oAuthJSON.callback_url, callbackUrl: oAuthJSON.callback_url,
tenantId: oAuthJSON.tenant_id ? parseInt(oAuthJSON.tenant_id) : null,
}); });
export const oAuthJS2JSON = (oAuth: IOAuth) => ({ export const oAuthJS2JSON = (oAuth: IOAuth) => ({
@@ -64,4 +67,5 @@ export const oAuthJS2JSON = (oAuth: IOAuth) => ({
json_user_name_path: oAuth.jsonUserNamePath, json_user_name_path: oAuth.jsonUserNamePath,
callback_url: oAuth.callbackUrl, callback_url: oAuth.callbackUrl,
tenant_id: oAuth.tenantId,
}); });

View File

@@ -3,6 +3,8 @@ class OAuth < ApplicationRecord
include ApplicationHelper include ApplicationHelper
include Rails.application.routes.url_helpers 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 attr_accessor :state
validates :name, presence: true, uniqueness: { scope: :tenant_id } validates :name, presence: true, uniqueness: { scope: :tenant_id }
@@ -15,8 +17,20 @@ class OAuth < ApplicationRecord
validates :scope, presence: true validates :scope, presence: true
validates :json_user_email_path, presence: true validates :json_user_email_path, presence: true
def is_default?
tenant_id == nil
end
def callback_url 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 end
def authorize_url_with_query_params def authorize_url_with_query_params

View File

@@ -9,6 +9,7 @@ class Tenant < ApplicationRecord
enum status: [:active, :pending, :blocked] enum status: [:active, :pending, :blocked]
after_initialize :set_default_status, if: :new_record? after_initialize :set_default_status, if: :new_record?
before_save :downcase_subdomain
validates :site_name, presence: true validates :site_name, presence: true
validates :subdomain, presence: true, uniqueness: true validates :subdomain, presence: true, uniqueness: true
@@ -18,4 +19,8 @@ class Tenant < ApplicationRecord
def set_default_status def set_default_status
self.status ||= :pending self.status ||= :pending
end end
def downcase_subdomain
self.subdomain = self.subdomain.downcase
end
end end

View File

@@ -16,8 +16,6 @@ class User < ApplicationRecord
after_initialize :set_default_role, if: :new_record? after_initialize :set_default_role, if: :new_record?
after_initialize :set_default_status, 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 :full_name, presence: true, length: { in: 2..32 }
validates :email, validates :email,
presence: true, presence: true,
@@ -54,7 +52,6 @@ class User < ApplicationRecord
end end
def skip_confirmation def skip_confirmation
return if Rails.application.email_confirmation?
skip_confirmation! skip_confirmation!
skip_confirmation_notification! skip_confirmation_notification!
skip_reconfirmation! skip_reconfirmation!
@@ -84,4 +81,15 @@ class User < ApplicationRecord
def blocked? def blocked?
status == 'blocked' status == 'blocked'
end 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 end

View File

@@ -1,55 +1,49 @@
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> <div class="new_user">
<h2><%= t('common.forms.auth.sign_up') %></h2> <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: "new_user_form" }) do |f| %>
<h2><%= t('common.forms.auth.sign_up') %></h2>
<%= render "devise/shared/error_messages", resource: resource %> <%= render "devise/shared/error_messages", resource: resource %>
<div class="form-group"> <div class="form-group">
<%= f.label :full_name, class: "sr-only" %> <%= f.label :full_name, class: "sr-only" %>
<%= f.text_field :full_name, <%= f.text_field :full_name,
autofocus: true, autofocus: true,
placeholder: t('common.forms.auth.full_name'), placeholder: t('common.forms.auth.full_name'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<div class="form-group"> <div class="form-group">
<%= f.label :email, class: "sr-only" %> <%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, <%= f.email_field :email,
autocomplete: "email", autocomplete: "email",
placeholder: t('common.forms.auth.email'), placeholder: t('common.forms.auth.email'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<div class="form-group"> <div class="form-group">
<%= f.label :password, class: "sr-only" %> <%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, <%= f.password_field :password,
placeholder: t('common.forms.auth.password'), placeholder: t('common.forms.auth.password'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<div class="form-group"> <div class="form-group">
<%= f.label :password_confirmation, class: "sr-only" %> <%= f.label :password_confirmation, class: "sr-only" %>
<%= f.password_field :password_confirmation, <%= f.password_field :password_confirmation,
placeholder: t('common.forms.auth.password_confirmation'), placeholder: t('common.forms.auth.password_confirmation'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<div class="actions"> <div class="actions">
<%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %> <%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %>
</div> </div>
<% if not @o_auths.blank? %>
<hr />
<% @o_auths.each do |o_auth| %>
<p>
<%= link_to t('common.forms.auth.sign_up_with', o_auth: o_auth.name),
o_auth_start_path(o_auth, reason: 'user') %>
</p>
<% end %>
<% end %> <% end %>
<% end %>
<%= render "devise/shared/o_auths", is_sign_up: true %>
</div>
<%= render "devise/shared/links" %> <%= render "devise/shared/links" %>

View File

@@ -1,44 +1,38 @@
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> <div class="new_user">
<h2><%= t('common.forms.auth.log_in') %></h2> <%= form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "new_user_form" }) do |f| %>
<h2><%= t('common.forms.auth.log_in') %></h2>
<div class="form-group"> <div class="form-group">
<%= f.label :email, class: "sr-only" %> <%= f.label :email, class: "sr-only" %>
<%= f.email_field :email, <%= f.email_field :email,
autocomplete: "email", autocomplete: "email",
placeholder: t('common.forms.auth.email'), placeholder: t('common.forms.auth.email'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<div class="form-group"> <div class="form-group">
<%= f.label :password, class: "sr-only" %> <%= f.label :password, class: "sr-only" %>
<%= f.password_field :password, <%= f.password_field :password,
autocomplete: "current-password", autocomplete: "current-password",
placeholder: t('common.forms.auth.password'), placeholder: t('common.forms.auth.password'),
required: true, required: true,
class: "form-control" %> class: "form-control" %>
</div> </div>
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<div class="form-group form-check"> <div class="form-group form-check">
<%= f.check_box :remember_me, class: "form-check-input" %> <%= f.check_box :remember_me, class: "form-check-input" %>
<%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %> <%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %>
</div>
<% end %>
<div class="actions">
<%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %>
</div> </div>
<% end %> <% end %>
<div class="actions"> <%= render "devise/shared/o_auths" %>
<%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %> </div>
</div>
<% if not @o_auths.empty? %>
<hr />
<% @o_auths.each do |o_auth| %>
<p>
<%= link_to t('common.forms.auth.log_in_with', o_auth: o_auth.name),
o_auth_start_path(o_auth, reason: 'user') %>
</p>
<% end %>
<% end %>
<% end %>
<%= render "devise/shared/links" %> <%= render "devise/shared/links" %>

View File

@@ -0,0 +1,17 @@
<% if not @o_auths.blank? %>
<hr />
<% @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 %>

View File

@@ -2,6 +2,11 @@
react_component( react_component(
'TenantSignUp', '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 authenticityToken: form_authenticity_token
} }
) )

View File

@@ -23,11 +23,11 @@ class OAuthSignInUser
def run def run
return nil unless @o_auth and @o_auth.class == OAuth and @o_auth.is_enabled? 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 begin
# Attempts to get email from user_profile Hash # 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) return nil if email.nil? or not URI::MailTo::EMAIL_REGEXP.match?(email)
@@ -36,7 +36,7 @@ class OAuthSignInUser
if user.nil? if user.nil?
if not @o_auth.json_user_name_path.blank? 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 end
full_name ||= I18n.t('defaults.user_full_name') full_name ||= I18n.t('defaults.user_full_name')
@@ -46,7 +46,7 @@ class OAuthSignInUser
password: Devise.friendly_token, password: Devise.friendly_token,
status: 'active' status: 'active'
) )
user.skip_confirmation! user.skip_confirmation
user.save user.save
end end

View File

@@ -24,10 +24,6 @@ module App
ENV["MULTI_TENANCY"] == "true" ENV["MULTI_TENANCY"] == "true"
end end
def email_confirmation?
ENV["EMAIL_CONFIRMATION"] == "true"
end
def posts_per_page def posts_per_page
15 15
end end

View File

@@ -81,12 +81,10 @@ Rails.application.configure do
} }
end end
if ENV['EMAIL_CONFIRMATION'] config.action_mailer.default_options = {
config.action_mailer.default_options = { from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"),
from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"),
reply_to: ENV.fetch("EMAIL_MAIL_REPLY_TO", ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>")) reply_to: ENV.fetch("EMAIL_MAIL_REPLY_TO", ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"))
} }
end
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found). # the I18n.default_locale when a translation cannot be found).

View File

@@ -3,9 +3,6 @@
# your test database is "scratch space" for the test suite and is wiped # 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! # 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 Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb. # Settings specified here will take precedence over those in config/application.rb.
# For Devise # For Devise

View File

@@ -69,12 +69,13 @@ en:
page_title: 'Create your feedback space' page_title: 'Create your feedback space'
step1: step1:
title: '1. Create user account' title: '1. Create user account'
email_auth: 'Register with email' email_auth: 'Sign up with email'
step2: step2:
title: '2. Create feedback space' title: '2. Create feedback space'
create_button: 'Create feedback space' create_button: 'Create feedback space'
validations: validations:
subdomain_already_taken: 'Sorry, this subdomain is not available' subdomain_already_taken: 'Sorry, this subdomain is not available'
subdomain_only_letters_and_numbers: 'Subdomain can only contain alphanumeric characters and hyphen'
step3: step3:
title: "You're almost done!" title: "You're almost done!"
message: "Check your email %{email} to activate your new feedback space %{subdomain}!" message: "Check your email %{email} to activate your new feedback space %{subdomain}!"
@@ -195,6 +196,7 @@ en:
authentication: authentication:
title: 'Authentication' title: 'Authentication'
oauth_subtitle: 'OAuth providers' oauth_subtitle: 'OAuth providers'
default_oauth: 'Default OAuth provider'
copy_url: 'Copy URL' copy_url: 'Copy URL'
test_page: test_page:
title: '%{name} OAuth test results' title: '%{name} OAuth test results'

View File

@@ -29,6 +29,7 @@ Rails.application.routes.draw do
resources :o_auths, only: [:index, :create, :update, :destroy] 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/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/: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 resources :posts, only: [:index, :create, :show, :update, :destroy] do
resource :follows, only: [:create, :destroy] resource :follows, only: [:create, :destroy]

View File

@@ -0,0 +1,5 @@
class RemoveNotNullConstraintToOAuthsTenant < ActiveRecord::Migration[6.1]
def change
change_column_null :o_auths, :tenant_id, true
end
end

View File

@@ -0,0 +1,5 @@
class AddOauthTokenToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :oauth_token, :string, null: true
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -77,7 +77,7 @@ ActiveRecord::Schema.define(version: 2023_02_11_095500) do
t.string "scope", null: false t.string "scope", null: false
t.string "json_user_name_path" t.string "json_user_name_path"
t.string "json_user_email_path", null: false 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 "created_at", precision: 6, null: false
t.datetime "updated_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 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.boolean "notifications_enabled", default: true, null: false
t.integer "status" t.integer "status"
t.bigint "tenant_id", null: false t.bigint "tenant_id", null: false
t.string "oauth_token"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true 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 ["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 t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View File

@@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe OAuthsHelper, type: :helper do 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 it 'queries a path from hash' do
email = "admin@example.com" email = "admin@example.com"
name = "Admin" name = "Admin"
@@ -23,14 +23,40 @@ RSpec.describe OAuthsHelper, type: :helper do
name_path = "info.name" name_path = "info.name"
surname_path = "info.additional_info.surnames[2][0].surname" 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_object(hash, name_path)).to eq(name)
expect(helper.query_path_from_hash(hash, email_path)).to eq(email) expect(helper.query_path_from_object(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, 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 end
it 'returns nil if inputs are not of type Hash and String respectively' do 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_object({"valid" => true}, ["invalid"])).to eq(nil)
expect(helper.query_path_from_hash("invalid", "valid")).to eq(nil) expect(helper.query_path_from_object("invalid", "valid")).to eq(nil)
end end
it 'returns nil if path not found' do it 'returns nil if path not found' do
@@ -44,10 +70,10 @@ RSpec.describe OAuthsHelper, type: :helper do
} }
name_path = "name" 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]" 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 end
end end