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;
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
def load_oauths
@o_auths = Current.tenant_or_raise!.o_auths
@o_auths = OAuth
.include_defaults
.where(is_enabled: true)
.order(created_at: :asc)
end

View File

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

View File

@@ -5,6 +5,7 @@ class TenantsController < ApplicationController
def new
@page_title = t('signup.page_title')
@o_auths = OAuth.unscoped.where(tenant_id: nil)
end
def show
@@ -16,17 +17,34 @@ class TenantsController < ApplicationController
@tenant.assign_attributes(tenant_create_params)
authorize @tenant
is_o_auth_login = params[:settings][:is_o_auth_login]
ActiveRecord::Base.transaction do
if is_o_auth_login
# Check if OAuth email and username coincide with submitted ones
# (session[:o_auth_sign_up] set in oauth#callback)
email, username = session[:o_auth_sign_up].split(",", 2)
raise "Mismatching email in OAuth login" unless email == params[:user][:email]
@tenant.status = "active" # no need to verify email address if logged in with oauth
end
@tenant.save!
Current.tenant = @tenant
@user = User.create!(
full_name: params[:user][:full_name],
@user = User.new(
full_name: params[:user][:full_name] || I18n.t('defaults.user_full_name'),
email: params[:user][:email],
password: params[:user][:password],
password: is_o_auth_login ? Devise.friendly_token : params[:user][:password],
role: "owner"
)
if is_o_auth_login
@user.skip_confirmation
end
@user.save!
render json: @tenant, status: :created
rescue ActiveRecord::RecordInvalid => exception

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,16 +4,24 @@ import I18n from 'i18n-js';
import Box from '../common/Box';
import Button from '../common/Button';
import OAuthProviderLink from '../common/OAuthProviderLink';
import { ITenantSignUpUserForm } from './TenantSignUpP';
import { DangerText } from '../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../helpers/formUtils';
import { EMAIL_REGEX } from '../../constants/regex';
import { IOAuth } from '../../interfaces/IOAuth';
import ActionLink from '../common/ActionLink';
import { BackIcon } from '../common/Icons';
interface Props {
currentStep: number;
setCurrentStep(step: number): void;
emailAuth: boolean;
setEmailAuth(enabled: boolean): void;
oAuths: Array<IOAuth>;
oAuthLoginCompleted: boolean;
oauthUserEmail?: string;
oauthUserName?: string;
userData: ITenantSignUpUserForm;
setUserData({}: ITenantSignUpUserForm): void;
}
@@ -23,6 +31,10 @@ const UserSignUpForm = ({
setCurrentStep,
emailAuth,
setEmailAuth,
oAuths,
oAuthLoginCompleted,
oauthUserEmail,
oauthUserName,
userData,
setUserData,
}: Props) => {
@@ -49,14 +61,37 @@ const UserSignUpForm = ({
{
currentStep === 1 && !emailAuth &&
<>
<Button className="emailAuth" onClick={() => setEmailAuth(true)}>
{ I18n.t('signup.step1.email_auth') }
</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 &&
<form onSubmit={handleSubmit(onSubmit)}>
<ActionLink
onClick={() => setEmailAuth(false)}
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<div className="formRow">
<input
{...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>
}
{
currentStep === 2 && oAuthLoginCompleted &&
<p><b>{oauthUserName}</b> ({oauthUserEmail})</p>
}
</Box>
);
}

View File

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

View File

@@ -21,6 +21,10 @@ export const MutedText = ({ children }: Props) => (
<span className="mutedText">{children}</span>
);
export const CenteredText = ({ children }: Props) => (
<p className="centeredText">{children}</p>
);
export const CenteredMutedText = ({ children }: Props) => (
<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,
siteName: string,
subdomain: string,
isOAuthLogin: boolean,
authenticityToken: string,
): Promise<any> {
return dispatch(submitTenant(
@@ -25,6 +26,7 @@ const mapDispatchToProps = (dispatch: any) => ({
userPassword,
siteName,
subdomain,
isOAuthLogin,
authenticityToken,
));
}

View File

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

View File

@@ -3,6 +3,8 @@ class OAuth < ApplicationRecord
include ApplicationHelper
include Rails.application.routes.url_helpers
scope :include_defaults, -> { unscope(where: :tenant_id).where(tenant_id: Current.tenant).or(unscope(where: :tenant_id).where(tenant_id: nil, is_enabled: true)) }
attr_accessor :state
validates :name, presence: true, uniqueness: { scope: :tenant_id }
@@ -15,8 +17,20 @@ class OAuth < ApplicationRecord
validates :scope, presence: true
validates :json_user_email_path, presence: true
def is_default?
tenant_id == nil
end
def callback_url
add_subdomain_to(method(:o_auth_callback_url), id)
# Default OAuths are available to all tenants
# but must have a single callback url:
# for this reason, we don't preprend tenant subdomain
# but rather use the "login" subdomain
if self.is_default?
o_auth_callback_url(id, host: Rails.application.base_url, subdomain: "login")
else
add_subdomain_to(method(:o_auth_callback_url), id)
end
end
def authorize_url_with_query_params

View File

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

View File

@@ -16,8 +16,6 @@ class User < ApplicationRecord
after_initialize :set_default_role, if: :new_record?
after_initialize :set_default_status, if: :new_record?
before_save :skip_confirmation
validates :full_name, presence: true, length: { in: 2..32 }
validates :email,
presence: true,
@@ -54,7 +52,6 @@ class User < ApplicationRecord
end
def skip_confirmation
return if Rails.application.email_confirmation?
skip_confirmation!
skip_confirmation_notification!
skip_reconfirmation!
@@ -84,4 +81,15 @@ class User < ApplicationRecord
def blocked?
status == 'blocked'
end
def generate_oauth_token
self.oauth_token = SecureRandom.urlsafe_base64
self.save!
oauth_token
end
def invalidate_oauth_token
self.oauth_token = nil
self.save!
end
end

View File

@@ -1,55 +1,49 @@
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<h2><%= t('common.forms.auth.sign_up') %></h2>
<div class="new_user">
<%= 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">
<%= f.label :full_name, class: "sr-only" %>
<%= f.text_field :full_name,
autofocus: true,
placeholder: t('common.forms.auth.full_name'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :full_name, class: "sr-only" %>
<%= f.text_field :full_name,
autofocus: true,
placeholder: t('common.forms.auth.full_name'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email,
autocomplete: "email",
placeholder: t('common.forms.auth.email'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email,
autocomplete: "email",
placeholder: t('common.forms.auth.email'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password,
placeholder: t('common.forms.auth.password'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password,
placeholder: t('common.forms.auth.password'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password_confirmation, class: "sr-only" %>
<%= f.password_field :password_confirmation,
placeholder: t('common.forms.auth.password_confirmation'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password_confirmation, class: "sr-only" %>
<%= f.password_field :password_confirmation,
placeholder: t('common.forms.auth.password_confirmation'),
required: true,
class: "form-control" %>
</div>
<div class="actions">
<%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %>
</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 %>
<div class="actions">
<%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %>
</div>
<% end %>
<% end %>
<%= render "devise/shared/o_auths", is_sign_up: true %>
</div>
<%= render "devise/shared/links" %>

View File

@@ -1,44 +1,38 @@
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<h2><%= t('common.forms.auth.log_in') %></h2>
<div class="new_user">
<%= 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">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email,
autocomplete: "email",
placeholder: t('common.forms.auth.email'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :email, class: "sr-only" %>
<%= f.email_field :email,
autocomplete: "email",
placeholder: t('common.forms.auth.email'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password,
autocomplete: "current-password",
placeholder: t('common.forms.auth.password'),
required: true,
class: "form-control" %>
</div>
<div class="form-group">
<%= f.label :password, class: "sr-only" %>
<%= f.password_field :password,
autocomplete: "current-password",
placeholder: t('common.forms.auth.password'),
required: true,
class: "form-control" %>
</div>
<% if devise_mapping.rememberable? %>
<div class="form-group form-check">
<%= f.check_box :remember_me, class: "form-check-input" %>
<%= f.label :remember_me, t('common.forms.auth.remember_me'), class: "form-check-label" %>
<% if devise_mapping.rememberable? %>
<div class="form-group form-check">
<%= f.check_box :remember_me, class: "form-check-input" %>
<%= 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>
<% end %>
<div class="actions">
<%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %>
</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/o_auths" %>
</div>
<%= 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(
'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
}
)

View File

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

View File

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

View File

@@ -81,12 +81,10 @@ Rails.application.configure do
}
end
if ENV['EMAIL_CONFIRMATION']
config.action_mailer.default_options = {
from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"),
config.action_mailer.default_options = {
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>"))
}
end
}
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# 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
# and recreated between test runs. Don't rely on the data there!
# Set up default environment variables
ENV["EMAIL_CONFIRMATION"] = "no"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# For Devise

View File

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

View File

@@ -29,6 +29,7 @@ Rails.application.routes.draw do
resources :o_auths, only: [:index, :create, :update, :destroy]
get '/o_auths/:id/start', to: 'o_auths#start', as: :o_auth_start
get '/o_auths/:id/callback', to: 'o_auths#callback', as: :o_auth_callback
get '/o_auths/sign_in_from_oauth_token', to: 'o_auths#sign_in_from_oauth_token', as: :o_auth_sign_in_from_oauth_token
resources :posts, only: [:index, :create, :show, :update, :destroy] do
resource :follows, only: [:create, :destroy]

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.
ActiveRecord::Schema.define(version: 2023_02_11_095500) do
ActiveRecord::Schema.define(version: 2024_01_17_112502) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -77,7 +77,7 @@ ActiveRecord::Schema.define(version: 2023_02_11_095500) do
t.string "scope", null: false
t.string "json_user_name_path"
t.string "json_user_email_path", null: false
t.bigint "tenant_id", null: false
t.bigint "tenant_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["name", "tenant_id"], name: "index_o_auths_on_name_and_tenant_id", unique: true
@@ -165,6 +165,7 @@ ActiveRecord::Schema.define(version: 2023_02_11_095500) do
t.boolean "notifications_enabled", default: true, null: false
t.integer "status"
t.bigint "tenant_id", null: false
t.string "oauth_token"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email", "tenant_id"], name: "index_users_on_email_and_tenant_id", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View File

@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe OAuthsHelper, type: :helper do
context 'query_path_from_hash method' do
context 'query_path_from_object method' do
it 'queries a path from hash' do
email = "admin@example.com"
name = "Admin"
@@ -23,14 +23,40 @@ RSpec.describe OAuthsHelper, type: :helper do
name_path = "info.name"
surname_path = "info.additional_info.surnames[2][0].surname"
expect(helper.query_path_from_hash(hash, name_path)).to eq(name)
expect(helper.query_path_from_hash(hash, email_path)).to eq(email)
expect(helper.query_path_from_hash(hash, surname_path)).to eq(surname)
expect(helper.query_path_from_object(hash, name_path)).to eq(name)
expect(helper.query_path_from_object(hash, email_path)).to eq(email)
expect(helper.query_path_from_object(hash, surname_path)).to eq(surname)
end
it 'queries a path from array' do
email1 = "admin1@example.com"
email2 = "admin2@example.com"
address = "Address1"
array = [
{
"email" => email1,
},
{
"email" => email2,
"additional_info" => {
"addresses" => [{ "name" => address }]
}
}
]
email1_path = "[0].email"
email2_path = "[1].email"
address_path = "[1].additional_info.addresses[0].name"
expect(helper.query_path_from_object(array, email1_path)).to eq(email1)
expect(helper.query_path_from_object(array, email2_path)).to eq(email2)
expect(helper.query_path_from_object(array, address_path)).to eq(address)
end
it 'returns nil if inputs are not of type Hash and String respectively' do
expect(helper.query_path_from_hash({"valid" => true}, ["invalid"])).to eq(nil)
expect(helper.query_path_from_hash("invalid", "valid")).to eq(nil)
expect(helper.query_path_from_object({"valid" => true}, ["invalid"])).to eq(nil)
expect(helper.query_path_from_object("invalid", "valid")).to eq(nil)
end
it 'returns nil if path not found' do
@@ -44,10 +70,10 @@ RSpec.describe OAuthsHelper, type: :helper do
}
name_path = "name"
expect(helper.query_path_from_hash(hash, name_path)).to eq(nil)
expect(helper.query_path_from_object(hash, name_path)).to eq(nil)
name_path = "info.names[0]"
expect(helper.query_path_from_hash(hash, name_path)).to eq(nil)
expect(helper.query_path_from_object(hash, name_path)).to eq(nil)
end
end
end