mirror of
https://github.com/astuto/astuto.git
synced 2025-12-15 19:27:52 +01:00
Add default OAuths (#259)
This commit is contained in:
committed by
GitHub
parent
0828c9c879
commit
653e139a9e
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
29
app/javascript/components/common/OAuthProviderLink.tsx
Normal file
29
app/javascript/components/common/OAuthProviderLink.tsx
Normal 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;
|
||||||
@@ -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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" %>
|
||||||
|
|||||||
@@ -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" %>
|
||||||
17
app/views/devise/shared/_o_auths.html.erb
Normal file
17
app/views/devise/shared/_o_auths.html.erb
Normal 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 %>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class RemoveNotNullConstraintToOAuthsTenant < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
change_column_null :o_auths, :tenant_id, true
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20240117112502_add_oauth_token_to_users.rb
Normal file
5
db/migrate/20240117112502_add_oauth_token_to_users.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AddOauthTokenToUsers < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :users, :oauth_token, :string, null: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user