mirror of
https://github.com/astuto/astuto.git
synced 2025-12-15 03:07:52 +01:00
Add OAuth2 authentication (#147)
- Added Site settings > Authentication section - Create/edit/delete your custom oauth2 configurations - Login or signup with oauth2
This commit is contained in:
committed by
GitHub
parent
3bda6dee08
commit
4c73b398e8
@@ -3,6 +3,7 @@
|
||||
# For more information check out this page:
|
||||
# https://github.com/riggraz/astuto/wiki/Required-environment-variables
|
||||
|
||||
BASE_URL=http://feedback.yoursitename.com
|
||||
ENVIRONMENT=production
|
||||
SECRET_KEY_BASE=secretkeybasehere
|
||||
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -19,6 +19,9 @@ gem 'jbuilder', '~> 2.7'
|
||||
|
||||
gem 'bootsnap', '>= 1.4.2', require: false
|
||||
|
||||
# HTTP requests
|
||||
gem 'httparty', '0.18.0'
|
||||
|
||||
# Authentication
|
||||
gem 'devise', '4.7.3'
|
||||
|
||||
|
||||
@@ -98,6 +98,9 @@ GEM
|
||||
ffi (1.15.5)
|
||||
globalid (1.0.0)
|
||||
activesupport (>= 5.0)
|
||||
httparty (0.18.0)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.10.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-js (3.9.2)
|
||||
@@ -129,10 +132,14 @@ GEM
|
||||
marcel (1.0.2)
|
||||
matrix (0.4.2)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2022.0105)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.8.0)
|
||||
minitest (5.15.0)
|
||||
msgpack (1.5.2)
|
||||
multi_xml (0.6.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.6)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
@@ -275,6 +282,7 @@ DEPENDENCIES
|
||||
capybara (>= 2.15)
|
||||
devise (= 4.7.3)
|
||||
factory_bot_rails (~> 5.0.2)
|
||||
httparty (= 0.18.0)
|
||||
i18n-js
|
||||
jbuilder (~> 2.7)
|
||||
kaminari (~> 1.2.1)
|
||||
|
||||
@@ -47,6 +47,12 @@ class ApplicationController < ActionController::Base
|
||||
I18n.locale = @tenant.locale
|
||||
end
|
||||
|
||||
def load_oauths
|
||||
@o_auths = Current.tenant_or_raise!.o_auths
|
||||
.where(is_enabled: true)
|
||||
.order(created_at: :asc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_not_authorized
|
||||
|
||||
136
app/controllers/o_auths_controller.rb
Normal file
136
app/controllers/o_auths_controller.rb
Normal file
@@ -0,0 +1,136 @@
|
||||
class OAuthsController < ApplicationController
|
||||
include HTTParty
|
||||
include OAuthsHelper
|
||||
include ApplicationHelper
|
||||
|
||||
before_action :authenticate_admin, only: [:index, :create, :update, :destroy]
|
||||
|
||||
# [subdomain.]base_url/o_auths/:id/start?reason=user|test
|
||||
# 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?
|
||||
|
||||
# Generate random state + other query params
|
||||
token_state = "#{params[:reason]}|#{Devise.friendly_token(30)}"
|
||||
session[:token_state] = token_state
|
||||
@o_auth.state = token_state
|
||||
|
||||
redirect_to @o_auth.authorize_url_with_query_params
|
||||
end
|
||||
|
||||
# [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('|')
|
||||
|
||||
return unless session[:token_state] == params[:state]
|
||||
|
||||
@o_auth = OAuth.find(params[:id])
|
||||
|
||||
user_profile = OAuthExchangeAuthCodeForProfile.new(
|
||||
authorization_code: params[:code],
|
||||
o_auth: @o_auth
|
||||
).run
|
||||
|
||||
if reason == 'user'
|
||||
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
|
||||
else
|
||||
flash[:alert] = I18n.t('errors.o_auth_login_error', { name: @o_auth.name })
|
||||
redirect_to new_user_session_path
|
||||
end
|
||||
elsif reason == 'test'
|
||||
unless user_signed_in? and current_user.admin?
|
||||
flash[:alert] = I18n.t('errors.unauthorized')
|
||||
redirect_to root_url
|
||||
return
|
||||
end
|
||||
|
||||
@user_profile = user_profile
|
||||
@user_email = query_path_from_hash(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?
|
||||
|
||||
render 'o_auths/test', layout: false
|
||||
else
|
||||
flash[:alert] = I18n.t('errors.unknown')
|
||||
redirect_to root_url
|
||||
end
|
||||
end
|
||||
|
||||
### CRUD actions below ###
|
||||
|
||||
def index
|
||||
authorize OAuth
|
||||
|
||||
@o_auths = OAuth.order(created_at: :asc)
|
||||
|
||||
render json: to_json_custom(@o_auths)
|
||||
end
|
||||
|
||||
def create
|
||||
@o_auth = OAuth.new
|
||||
@o_auth.assign_attributes(o_auth_params)
|
||||
authorize @o_auth
|
||||
|
||||
if @o_auth.save
|
||||
render json: to_json_custom(@o_auth), status: :created
|
||||
else
|
||||
render json: {
|
||||
error: @o_auth.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@o_auth = OAuth.find(params[:id])
|
||||
authorize @o_auth
|
||||
|
||||
if @o_auth.update(o_auth_params)
|
||||
render json: to_json_custom(@o_auth)
|
||||
else
|
||||
render json: {
|
||||
error: @o_auth.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@o_auth = OAuth.find(params[:id])
|
||||
authorize @o_auth
|
||||
|
||||
if @o_auth.destroy
|
||||
render json: {
|
||||
id: params[:id]
|
||||
}, status: :accepted
|
||||
else
|
||||
render json: {
|
||||
error: @o_auth.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def to_json_custom(o_auth)
|
||||
o_auth.as_json(
|
||||
methods: :callback_url,
|
||||
except: [:client_secret]
|
||||
)
|
||||
end
|
||||
|
||||
def o_auth_params
|
||||
params
|
||||
.require(:o_auth)
|
||||
.permit(policy(@o_auth).permitted_attributes)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
class RegistrationsController < Devise::RegistrationsController
|
||||
# Needed to have Current.tenant available in Devise's controllers
|
||||
prepend_before_action :load_tenant_data
|
||||
before_action :load_oauths, only: [:new]
|
||||
|
||||
# Override destroy to soft delete
|
||||
def destroy
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class SessionsController < Devise::SessionsController
|
||||
# Needed to have Current.tenant available in Devise's controllers
|
||||
prepend_before_action :load_tenant_data
|
||||
before_action :load_oauths, only: [:new]
|
||||
end
|
||||
@@ -1,8 +1,11 @@
|
||||
class SiteSettingsController < ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
before_action :authenticate_admin, only: [:general, :boards, :post_statuses, :roadmap]
|
||||
before_action :authenticate_power_user, only: [:users]
|
||||
before_action :authenticate_admin,
|
||||
only: [:general, :boards, :post_statuses, :roadmap, :authentication]
|
||||
|
||||
before_action :authenticate_power_user,
|
||||
only: [:users]
|
||||
|
||||
def general
|
||||
end
|
||||
@@ -18,4 +21,7 @@ class SiteSettingsController < ApplicationController
|
||||
|
||||
def users
|
||||
end
|
||||
|
||||
def authentication
|
||||
end
|
||||
end
|
||||
@@ -30,6 +30,7 @@ module ApplicationHelper
|
||||
|
||||
def add_subdomain_to(url_helper, resource=nil, options={})
|
||||
options[:subdomain] = Current.tenant_or_raise!.subdomain if Rails.application.multi_tenancy?
|
||||
options[:host] = Rails.application.base_url
|
||||
|
||||
resource ? url_helper.call(resource, options) : url_helper.call(options)
|
||||
end
|
||||
|
||||
26
app/helpers/o_auths_helper.rb
Normal file
26
app/helpers/o_auths_helper.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
path_array.each do |selector|
|
||||
break if hash == nil
|
||||
|
||||
hash = hash[selector]
|
||||
end
|
||||
|
||||
hash
|
||||
end
|
||||
end
|
||||
69
app/javascript/actions/OAuth/deleteOAuth.ts
Normal file
69
app/javascript/actions/OAuth/deleteOAuth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Action } from "redux";
|
||||
import { ThunkAction } from "redux-thunk";
|
||||
import HttpStatus from "../../constants/http_status";
|
||||
|
||||
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
|
||||
import { State } from "../../reducers/rootReducer";
|
||||
|
||||
export const OAUTH_DELETE_START = 'OAUTH_DELETE_START';
|
||||
interface OAuthDeleteStartAction {
|
||||
type: typeof OAUTH_DELETE_START;
|
||||
}
|
||||
|
||||
export const OAUTH_DELETE_SUCCESS = 'OAUTH_DELETE_SUCCESS';
|
||||
interface OAuthDeleteSuccessAction {
|
||||
type: typeof OAUTH_DELETE_SUCCESS;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export const OAUTH_DELETE_FAILURE = 'OAUTH_DELETE_FAILURE';
|
||||
interface OAuthDeleteFailureAction {
|
||||
type: typeof OAUTH_DELETE_FAILURE;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OAuthDeleteActionTypes =
|
||||
OAuthDeleteStartAction |
|
||||
OAuthDeleteSuccessAction |
|
||||
OAuthDeleteFailureAction;
|
||||
|
||||
const oAuthDeleteStart = (): OAuthDeleteStartAction => ({
|
||||
type: OAUTH_DELETE_START,
|
||||
});
|
||||
|
||||
const oAuthDeleteSuccess = (
|
||||
id: number,
|
||||
): OAuthDeleteSuccessAction => ({
|
||||
type: OAUTH_DELETE_SUCCESS,
|
||||
id,
|
||||
});
|
||||
|
||||
const oAuthDeleteFailure = (error: string): OAuthDeleteFailureAction => ({
|
||||
type: OAUTH_DELETE_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const deleteOAuth = (
|
||||
id: number,
|
||||
authenticityToken: string,
|
||||
): ThunkAction<void, State, null, Action<string>> => (
|
||||
async (dispatch) => {
|
||||
dispatch(oAuthDeleteStart());
|
||||
|
||||
try {
|
||||
const res = await fetch(`/o_auths/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildRequestHeaders(authenticityToken),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (res.status === HttpStatus.Accepted) {
|
||||
dispatch(oAuthDeleteSuccess(id));
|
||||
} else {
|
||||
dispatch(oAuthDeleteFailure(json.error));
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch(oAuthDeleteFailure(e));
|
||||
}
|
||||
}
|
||||
);
|
||||
60
app/javascript/actions/OAuth/requestOAuths.ts
Normal file
60
app/javascript/actions/OAuth/requestOAuths.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Action } from 'redux';
|
||||
import { ThunkAction } from 'redux-thunk';
|
||||
|
||||
import { IOAuthJSON } from '../../interfaces/IOAuth';
|
||||
|
||||
import { State } from '../../reducers/rootReducer';
|
||||
|
||||
export const OAUTHS_REQUEST_START = 'OAUTHS_REQUEST_START';
|
||||
interface OAuthsRequestStartAction {
|
||||
type: typeof OAUTHS_REQUEST_START;
|
||||
}
|
||||
|
||||
export const OAUTHS_REQUEST_SUCCESS = 'OAUTHS_REQUEST_SUCCESS';
|
||||
interface OAuthsRequestSuccessAction {
|
||||
type: typeof OAUTHS_REQUEST_SUCCESS;
|
||||
oAuths: Array<IOAuthJSON>;
|
||||
}
|
||||
|
||||
export const OAUTHS_REQUEST_FAILURE = 'OAUTHS_REQUEST_FAILURE';
|
||||
interface OAuthsRequestFailureAction {
|
||||
type: typeof OAUTHS_REQUEST_FAILURE;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OAuthsRequestActionTypes =
|
||||
OAuthsRequestStartAction |
|
||||
OAuthsRequestSuccessAction |
|
||||
OAuthsRequestFailureAction;
|
||||
|
||||
|
||||
const oAuthsRequestStart = (): OAuthsRequestActionTypes => ({
|
||||
type: OAUTHS_REQUEST_START,
|
||||
});
|
||||
|
||||
const oAuthsRequestSuccess = (
|
||||
oAuths: Array<IOAuthJSON>
|
||||
): OAuthsRequestActionTypes => ({
|
||||
type: OAUTHS_REQUEST_SUCCESS,
|
||||
oAuths,
|
||||
});
|
||||
|
||||
const oAuthsRequestFailure = (error: string): OAuthsRequestActionTypes => ({
|
||||
type: OAUTHS_REQUEST_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const requestOAuths = (): ThunkAction<void, State, null, Action<string>> => (
|
||||
async (dispatch) => {
|
||||
dispatch(oAuthsRequestStart());
|
||||
|
||||
try {
|
||||
const response = await fetch('/o_auths');
|
||||
const json = await response.json();
|
||||
|
||||
dispatch(oAuthsRequestSuccess(json));
|
||||
} catch (e) {
|
||||
dispatch(oAuthsRequestFailure(e));
|
||||
}
|
||||
}
|
||||
)
|
||||
78
app/javascript/actions/OAuth/submitOAuth.ts
Normal file
78
app/javascript/actions/OAuth/submitOAuth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Action } from "redux";
|
||||
import { ThunkAction } from "redux-thunk";
|
||||
|
||||
import HttpStatus from "../../constants/http_status";
|
||||
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
|
||||
import { IOAuth, IOAuthJSON, oAuthJS2JSON } from "../../interfaces/IOAuth";
|
||||
import { State } from "../../reducers/rootReducer";
|
||||
|
||||
export const OAUTH_SUBMIT_START = 'OAUTH_SUBMIT_START';
|
||||
interface OAuthSubmitStartAction {
|
||||
type: typeof OAUTH_SUBMIT_START;
|
||||
}
|
||||
|
||||
export const OAUTH_SUBMIT_SUCCESS = 'OAUTH_SUBMIT_SUCCESS';
|
||||
interface OAuthSubmitSuccessAction {
|
||||
type: typeof OAUTH_SUBMIT_SUCCESS;
|
||||
oAuth: IOAuthJSON;
|
||||
}
|
||||
|
||||
export const OAUTH_SUBMIT_FAILURE = 'OAUTH_SUBMIT_FAILURE';
|
||||
interface OAuthSubmitFailureAction {
|
||||
type: typeof OAUTH_SUBMIT_FAILURE;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OAuthSubmitActionTypes =
|
||||
OAuthSubmitStartAction |
|
||||
OAuthSubmitSuccessAction |
|
||||
OAuthSubmitFailureAction;
|
||||
|
||||
const oAuthSubmitStart = (): OAuthSubmitStartAction => ({
|
||||
type: OAUTH_SUBMIT_START,
|
||||
});
|
||||
|
||||
const oAuthSubmitSuccess = (
|
||||
oAuthJSON: IOAuthJSON,
|
||||
): OAuthSubmitSuccessAction => ({
|
||||
type: OAUTH_SUBMIT_SUCCESS,
|
||||
oAuth: oAuthJSON,
|
||||
});
|
||||
|
||||
const oAuthSubmitFailure = (error: string): OAuthSubmitFailureAction => ({
|
||||
type: OAUTH_SUBMIT_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const submitOAuth = (
|
||||
oAuth: IOAuth,
|
||||
authenticityToken: string,
|
||||
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
|
||||
dispatch(oAuthSubmitStart());
|
||||
|
||||
try {
|
||||
const res = await fetch(`/o_auths`, {
|
||||
method: 'POST',
|
||||
headers: buildRequestHeaders(authenticityToken),
|
||||
body: JSON.stringify({
|
||||
o_auth: {
|
||||
...oAuthJS2JSON(oAuth),
|
||||
is_enabled: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (res.status === HttpStatus.Created) {
|
||||
dispatch(oAuthSubmitSuccess(json));
|
||||
} else {
|
||||
dispatch(oAuthSubmitFailure(json.error));
|
||||
}
|
||||
|
||||
return Promise.resolve(res);
|
||||
} catch (e) {
|
||||
dispatch(oAuthSubmitFailure(e));
|
||||
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
};
|
||||
98
app/javascript/actions/OAuth/updateOAuth.ts
Normal file
98
app/javascript/actions/OAuth/updateOAuth.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Action } from "redux";
|
||||
import { ThunkAction } from "redux-thunk";
|
||||
import { ISiteSettingsOAuthForm } from "../../components/SiteSettings/Authentication/OAuthForm";
|
||||
import HttpStatus from "../../constants/http_status";
|
||||
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
|
||||
import { IOAuthJSON } from "../../interfaces/IOAuth";
|
||||
import { State } from "../../reducers/rootReducer";
|
||||
|
||||
export const OAUTH_UPDATE_START = 'OAUTH_UPDATE_START';
|
||||
interface OAuthUpdateStartAction {
|
||||
type: typeof OAUTH_UPDATE_START;
|
||||
}
|
||||
|
||||
export const OAUTH_UPDATE_SUCCESS = 'OAUTH_UPDATE_SUCCESS';
|
||||
interface OAuthUpdateSuccessAction {
|
||||
type: typeof OAUTH_UPDATE_SUCCESS;
|
||||
oAuth: IOAuthJSON;
|
||||
}
|
||||
|
||||
export const OAUTH_UPDATE_FAILURE = 'OAUTH_UPDATE_FAILURE';
|
||||
interface OAuthUpdateFailureAction {
|
||||
type: typeof OAUTH_UPDATE_FAILURE;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OAuthUpdateActionTypes =
|
||||
OAuthUpdateStartAction |
|
||||
OAuthUpdateSuccessAction |
|
||||
OAuthUpdateFailureAction;
|
||||
|
||||
const oAuthUpdateStart = (): OAuthUpdateStartAction => ({
|
||||
type: OAUTH_UPDATE_START,
|
||||
});
|
||||
|
||||
const oAuthUpdateSuccess = (
|
||||
oAuthJSON: IOAuthJSON,
|
||||
): OAuthUpdateSuccessAction => ({
|
||||
type: OAUTH_UPDATE_SUCCESS,
|
||||
oAuth: oAuthJSON,
|
||||
});
|
||||
|
||||
const oAuthUpdateFailure = (error: string): OAuthUpdateFailureAction => ({
|
||||
type: OAUTH_UPDATE_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
interface UpdateOAuthParams {
|
||||
id: number;
|
||||
form?: ISiteSettingsOAuthForm;
|
||||
isEnabled?: boolean;
|
||||
authenticityToken: string;
|
||||
}
|
||||
|
||||
export const updateOAuth = ({
|
||||
id,
|
||||
form = null,
|
||||
isEnabled = null,
|
||||
authenticityToken,
|
||||
}: UpdateOAuthParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
|
||||
dispatch(oAuthUpdateStart());
|
||||
|
||||
const o_auth = Object.assign({},
|
||||
form !== null ? {
|
||||
name: form.name,
|
||||
logo: form.logo,
|
||||
client_id: form.clientId,
|
||||
client_secret: form.clientSecret,
|
||||
authorize_url: form.authorizeUrl,
|
||||
token_url: form.tokenUrl,
|
||||
profile_url: form.profileUrl,
|
||||
scope: form.scope,
|
||||
json_user_email_path: form.jsonUserEmailPath,
|
||||
json_user_name_path: form.jsonUserNamePath,
|
||||
} : null,
|
||||
isEnabled !== null ? {is_enabled: isEnabled} : null,
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/o_auths/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: buildRequestHeaders(authenticityToken),
|
||||
body: JSON.stringify({o_auth}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (res.status === HttpStatus.OK) {
|
||||
dispatch(oAuthUpdateSuccess(json));
|
||||
} else {
|
||||
dispatch(oAuthUpdateFailure(json.error));
|
||||
}
|
||||
|
||||
return Promise.resolve(res);
|
||||
} catch (e) {
|
||||
dispatch(oAuthUpdateFailure(e));
|
||||
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import Box from '../../common/Box';
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
import OAuthForm, { ISiteSettingsOAuthForm } from './OAuthForm';
|
||||
import Spinner from '../../common/Spinner';
|
||||
import { DangerText } from '../../common/CustomTexts';
|
||||
import { IOAuth } from '../../../interfaces/IOAuth';
|
||||
|
||||
interface Props {
|
||||
handleSubmitOAuth(oAuth: IOAuth): void;
|
||||
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
|
||||
isSubmitting: boolean;
|
||||
submitError: string;
|
||||
selectedOAuth: IOAuth;
|
||||
page: AuthenticationPages;
|
||||
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
|
||||
}
|
||||
|
||||
const AuthenticationFormPage = ({
|
||||
handleSubmitOAuth,
|
||||
handleUpdateOAuth,
|
||||
isSubmitting,
|
||||
submitError,
|
||||
selectedOAuth,
|
||||
page,
|
||||
setPage,
|
||||
}: Props) => (
|
||||
<>
|
||||
<Box customClass="authenticationFormPage">
|
||||
<OAuthForm
|
||||
handleSubmitOAuth={handleSubmitOAuth}
|
||||
handleUpdateOAuth={handleUpdateOAuth}
|
||||
selectedOAuth={selectedOAuth}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
/>
|
||||
|
||||
{ isSubmitting && <Spinner /> }
|
||||
{ submitError && <DangerText>{submitError}</DangerText> }
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
export default AuthenticationFormPage;
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import Box from '../../common/Box';
|
||||
import OAuthProvidersList from './OAuthProvidersList';
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
import { OAuthsState } from '../../../reducers/oAuthsReducer';
|
||||
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
|
||||
|
||||
interface Props {
|
||||
oAuths: OAuthsState;
|
||||
isSubmitting: boolean;
|
||||
submitError: string;
|
||||
|
||||
handleToggleEnabledOAuth(id: number, enabled: boolean): void;
|
||||
handleDeleteOAuth(id: number): void;
|
||||
|
||||
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
|
||||
setSelectedOAuth: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const AuthenticationIndexPage = ({
|
||||
oAuths,
|
||||
isSubmitting,
|
||||
submitError,
|
||||
|
||||
handleToggleEnabledOAuth,
|
||||
handleDeleteOAuth,
|
||||
|
||||
setPage,
|
||||
setSelectedOAuth,
|
||||
}: Props) => (
|
||||
<>
|
||||
<Box customClass="authenticationIndexPage">
|
||||
<h2>{ I18n.t('site_settings.authentication.title') }</h2>
|
||||
|
||||
<OAuthProvidersList
|
||||
oAuths={oAuths.items}
|
||||
handleToggleEnabledOAuth={handleToggleEnabledOAuth}
|
||||
handleDeleteOAuth={handleDeleteOAuth}
|
||||
setPage={setPage}
|
||||
setSelectedOAuth={setSelectedOAuth}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<SiteSettingsInfoBox
|
||||
areUpdating={oAuths.areLoading || isSubmitting}
|
||||
error={oAuths.error || submitError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export default AuthenticationIndexPage;
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import HttpStatus from '../../../constants/http_status';
|
||||
import { IOAuth } from '../../../interfaces/IOAuth';
|
||||
import { OAuthsState } from '../../../reducers/oAuthsReducer';
|
||||
|
||||
import AuthenticationFormPage from './AuthenticationFormPage';
|
||||
import AuthenticationIndexPage from './AuthenticationIndexPage';
|
||||
import { ISiteSettingsOAuthForm } from './OAuthForm';
|
||||
|
||||
interface Props {
|
||||
oAuths: OAuthsState;
|
||||
|
||||
requestOAuths(): void;
|
||||
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any>;
|
||||
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any>;
|
||||
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string): void;
|
||||
onDeleteOAuth(id: number, authenticityToken: string): void;
|
||||
|
||||
isSubmitting: boolean;
|
||||
submitError: string;
|
||||
|
||||
authenticityToken: string;
|
||||
}
|
||||
|
||||
export type AuthenticationPages = 'index' | 'new' | 'edit';
|
||||
|
||||
const AuthenticationSiteSettingsP = ({
|
||||
oAuths,
|
||||
requestOAuths,
|
||||
onSubmitOAuth,
|
||||
onUpdateOAuth,
|
||||
onToggleEnabledOAuth,
|
||||
onDeleteOAuth,
|
||||
isSubmitting,
|
||||
submitError,
|
||||
authenticityToken,
|
||||
}: Props) => {
|
||||
const [page, setPage] = useState<AuthenticationPages>('index');
|
||||
const [selectedOAuth, setSelectedOAuth] = useState<number>(null);
|
||||
|
||||
useEffect(requestOAuths, []);
|
||||
|
||||
const handleSubmitOAuth = (oAuth: IOAuth) => {
|
||||
onSubmitOAuth(oAuth, authenticityToken).then(res => {
|
||||
if (res?.status === HttpStatus.Created) setPage('index');
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateOAuth = (id: number, form: ISiteSettingsOAuthForm) => {
|
||||
onUpdateOAuth(id, form, authenticityToken).then(res => {
|
||||
if (res?.status === HttpStatus.OK) setPage('index');
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleEnabledOAuth = (id: number, enabled: boolean) => {
|
||||
onToggleEnabledOAuth(id, enabled, authenticityToken);
|
||||
};
|
||||
|
||||
const handleDeleteOAuth = (id: number) => {
|
||||
onDeleteOAuth(id, authenticityToken);
|
||||
};
|
||||
|
||||
return (
|
||||
page === 'index' ?
|
||||
<AuthenticationIndexPage
|
||||
oAuths={oAuths}
|
||||
handleToggleEnabledOAuth={handleToggleEnabledOAuth}
|
||||
handleDeleteOAuth={handleDeleteOAuth}
|
||||
setPage={setPage}
|
||||
setSelectedOAuth={setSelectedOAuth}
|
||||
isSubmitting={isSubmitting}
|
||||
submitError={submitError}
|
||||
/>
|
||||
:
|
||||
<AuthenticationFormPage
|
||||
handleSubmitOAuth={handleSubmitOAuth}
|
||||
handleUpdateOAuth={handleUpdateOAuth}
|
||||
isSubmitting={isSubmitting}
|
||||
submitError={submitError}
|
||||
selectedOAuth={oAuths.items.find(oAuth => oAuth.id === selectedOAuth)}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticationSiteSettingsP;
|
||||
@@ -0,0 +1,247 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { DangerText } from '../../common/CustomTexts';
|
||||
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
|
||||
import Button from '../../common/Button';
|
||||
import { URL_REGEX } from '../../../constants/regex';
|
||||
import { IOAuth } from '../../../interfaces/IOAuth';
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
import { useState } from 'react';
|
||||
import Separator from '../../common/Separator';
|
||||
|
||||
interface Props {
|
||||
selectedOAuth: IOAuth;
|
||||
page: AuthenticationPages;
|
||||
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
|
||||
|
||||
handleSubmitOAuth(oAuth: IOAuth): void;
|
||||
handleUpdateOAuth(id: number, form: ISiteSettingsOAuthForm): void;
|
||||
}
|
||||
|
||||
export interface ISiteSettingsOAuthForm {
|
||||
name: string;
|
||||
logo: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
profileUrl: string;
|
||||
scope: string;
|
||||
jsonUserEmailPath: string;
|
||||
jsonUserNamePath: string;
|
||||
}
|
||||
|
||||
const OAuthForm = ({
|
||||
selectedOAuth,
|
||||
page,
|
||||
setPage,
|
||||
|
||||
handleSubmitOAuth,
|
||||
handleUpdateOAuth,
|
||||
}: Props) => {
|
||||
const [editClientSecret, setEditClientSecret] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty }
|
||||
} = useForm<ISiteSettingsOAuthForm>({
|
||||
defaultValues: page === 'new' ? {
|
||||
name: '',
|
||||
logo: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authorizeUrl: '',
|
||||
tokenUrl: '',
|
||||
profileUrl: '',
|
||||
scope: '',
|
||||
jsonUserEmailPath: '',
|
||||
jsonUserNamePath: '',
|
||||
} : {
|
||||
name: selectedOAuth.name,
|
||||
logo: selectedOAuth.logo,
|
||||
clientId: selectedOAuth.clientId,
|
||||
clientSecret: selectedOAuth.clientSecret,
|
||||
authorizeUrl: selectedOAuth.authorizeUrl,
|
||||
tokenUrl: selectedOAuth.tokenUrl,
|
||||
profileUrl: selectedOAuth.profileUrl,
|
||||
scope: selectedOAuth.scope,
|
||||
jsonUserEmailPath: selectedOAuth.jsonUserEmailPath,
|
||||
jsonUserNamePath: selectedOAuth.jsonUserNamePath,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<ISiteSettingsOAuthForm> = data => {
|
||||
const oAuth = { ...data, isEnabled: false };
|
||||
|
||||
if (page === 'new') {
|
||||
handleSubmitOAuth(oAuth);
|
||||
} else if (page === 'edit') {
|
||||
if (editClientSecret === false) {
|
||||
delete oAuth.clientSecret;
|
||||
}
|
||||
|
||||
handleUpdateOAuth(selectedOAuth.id, oAuth as ISiteSettingsOAuthForm);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
onClick={() => {
|
||||
let confirmation = true;
|
||||
if (isDirty)
|
||||
confirmation = confirm(I18n.t('common.unsaved_changes') + ' ' + I18n.t('common.confirmation'));
|
||||
if (confirmation) setPage('index');
|
||||
}}
|
||||
className="backButton link">
|
||||
← { I18n.t('common.buttons.back') }
|
||||
</a>
|
||||
<h2>{ I18n.t(`site_settings.authentication.form.title_${page}`) }</h2>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="formRow">
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="name">{ getLabel('o_auth', 'name') }</label>
|
||||
<input
|
||||
{...register('name', { required: true })}
|
||||
id="name"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.name && getValidationMessage(errors.name.type, 'o_auth', 'name')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="logo">{ getLabel('o_auth', 'logo') }</label>
|
||||
<input
|
||||
{...register('logo')}
|
||||
id="logo"
|
||||
className="formControl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }</h5>
|
||||
<div className="formRow">
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="clientId">{ getLabel('o_auth', 'client_id') }</label>
|
||||
<input
|
||||
{...register('clientId', { required: true })}
|
||||
id="clientId"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.clientId && getValidationMessage(errors.clientId.type, 'o_auth', 'client_id')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="clientSecret">{ getLabel('o_auth', 'client_secret') }</label>
|
||||
<input
|
||||
{...register('clientSecret', { required: page === 'new' || (page === 'edit' && editClientSecret) })}
|
||||
id="clientSecret"
|
||||
className="formControl"
|
||||
disabled={page === 'edit' && editClientSecret === false}
|
||||
/>
|
||||
{
|
||||
page === 'edit' &&
|
||||
<>
|
||||
<small>
|
||||
{I18n.t('site_settings.authentication.form.client_secret_help') + "\t"}
|
||||
</small>
|
||||
<Separator />
|
||||
{
|
||||
editClientSecret ?
|
||||
<a onClick={() => setEditClientSecret(false)} className="link">{I18n.t('common.buttons.cancel')}</a>
|
||||
:
|
||||
<a onClick={() => setEditClientSecret(true)} className="link">{I18n.t('common.buttons.edit')}</a>
|
||||
}
|
||||
<br />
|
||||
</>
|
||||
|
||||
}
|
||||
<DangerText>{errors.clientSecret && getValidationMessage(errors.clientSecret.type, 'o_auth', 'client_secret')}</DangerText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="formRow">
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="authorizeUrl">{ getLabel('o_auth', 'authorize_url') }</label>
|
||||
<input
|
||||
{...register('authorizeUrl', { required: true, pattern: URL_REGEX })}
|
||||
id="authorizeUrl"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.authorizeUrl?.type === 'required' && getValidationMessage(errors.authorizeUrl.type, 'o_auth', 'authorize_url')}</DangerText>
|
||||
<DangerText>{errors.authorizeUrl?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="tokenUrl">{ getLabel('o_auth', 'token_url') }</label>
|
||||
<input
|
||||
{...register('tokenUrl', { required: true, pattern: URL_REGEX })}
|
||||
id="tokenUrl"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.tokenUrl?.type === 'required' && getValidationMessage(errors.tokenUrl.type, 'o_auth', 'token_url')}</DangerText>
|
||||
<DangerText>{errors.tokenUrl?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="formGroup">
|
||||
<label htmlFor="scope">{ getLabel('o_auth', 'scope') }</label>
|
||||
<input
|
||||
{...register('scope', { required: true })}
|
||||
id="scope"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.scope && getValidationMessage(errors.scope.type, 'o_auth', 'scope')}</DangerText>
|
||||
</div>
|
||||
|
||||
<h5>{ I18n.t('site_settings.authentication.form.subtitle_user_profile_config') }</h5>
|
||||
<div className="formGroup">
|
||||
<label htmlFor="profileUrl">{ getLabel('o_auth', 'profile_url') }</label>
|
||||
<input
|
||||
{...register('profileUrl', { required: true, pattern: URL_REGEX })}
|
||||
id="profileUrl"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.profileUrl?.type === 'required' && getValidationMessage(errors.profileUrl.type, 'o_auth', 'profile_url')}</DangerText>
|
||||
<DangerText>{errors.profileUrl?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formRow">
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="jsonUserEmailPath">{ getLabel('o_auth', 'json_user_email_path') }</label>
|
||||
<input
|
||||
{...register('jsonUserEmailPath', { required: true })}
|
||||
id="jsonUserEmailPath"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>
|
||||
{errors.jsonUserEmailPath && getValidationMessage(errors.jsonUserEmailPath.type, 'o_auth', 'json_user_email_path')}
|
||||
</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-6">
|
||||
<label htmlFor="jsonUserNamePath">{ getLabel('o_auth', 'json_user_name_path') }</label>
|
||||
<input
|
||||
{...register('jsonUserNamePath')}
|
||||
id="jsonUserNamePath"
|
||||
className="formControl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => null}>
|
||||
{
|
||||
page === 'new' ?
|
||||
I18n.t('common.buttons.create')
|
||||
:
|
||||
I18n.t('common.buttons.update')
|
||||
}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OAuthForm;
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
oAuth: IOAuth;
|
||||
handleToggleEnabledOAuth(id: number, enabled: boolean): void;
|
||||
handleDeleteOAuth(id: number): void;
|
||||
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
|
||||
setSelectedOAuth: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const OAuthProviderItem = ({
|
||||
oAuth,
|
||||
handleToggleEnabledOAuth,
|
||||
handleDeleteOAuth,
|
||||
setPage,
|
||||
setSelectedOAuth,
|
||||
}: Props) => (
|
||||
<li className="oAuthListItem">
|
||||
<div className="oAuthInfo">
|
||||
<img src={oAuth.logo} className="oAuthLogo" width={42} height={42} />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="oAuthActions">
|
||||
<CopyToClipboardButton
|
||||
label={I18n.t('site_settings.authentication.copy_url')}
|
||||
textToCopy={oAuth.callbackUrl}
|
||||
/>
|
||||
<Separator />
|
||||
<a onClick={() =>
|
||||
window.open(`/o_auths/${oAuth.id}/start?reason=test`, '', 'width=640, height=640')
|
||||
}>
|
||||
Test
|
||||
</a>
|
||||
<Separator />
|
||||
<a onClick={() => {
|
||||
setSelectedOAuth(oAuth.id);
|
||||
setPage('edit');
|
||||
}}>
|
||||
{ I18n.t('common.buttons.edit') }
|
||||
</a>
|
||||
<Separator />
|
||||
<a onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteOAuth(oAuth.id)}>
|
||||
{ I18n.t('common.buttons.delete') }
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
export default OAuthProviderItem;
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
import Button from '../../common/Button';
|
||||
import { IOAuth } from '../../../interfaces/IOAuth';
|
||||
import OAuthProviderItem from './OAuthProviderItem';
|
||||
|
||||
interface Props {
|
||||
oAuths: Array<IOAuth>;
|
||||
handleToggleEnabledOAuth(id: number, enabled: boolean): void;
|
||||
handleDeleteOAuth(id: number): void;
|
||||
setPage: React.Dispatch<React.SetStateAction<AuthenticationPages>>;
|
||||
setSelectedOAuth: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
const OAuthProvidersList = ({
|
||||
oAuths,
|
||||
handleToggleEnabledOAuth,
|
||||
handleDeleteOAuth,
|
||||
setPage,
|
||||
setSelectedOAuth,
|
||||
}: Props) => (
|
||||
<>
|
||||
<div className="oauthProvidersTitle">
|
||||
<h3>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h3>
|
||||
<Button onClick={() => setPage('new')}>
|
||||
{ I18n.t('common.buttons.new') }
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="oAuthsList">
|
||||
{
|
||||
oAuths.map((oAuth, i) => (
|
||||
<OAuthProviderItem
|
||||
oAuth={oAuth}
|
||||
handleToggleEnabledOAuth={handleToggleEnabledOAuth}
|
||||
handleDeleteOAuth={handleDeleteOAuth}
|
||||
setPage={setPage}
|
||||
setSelectedOAuth={setSelectedOAuth}
|
||||
key={i}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
export default OAuthProvidersList;
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
|
||||
import AuthenticationSiteSettings from '../../../containers/AuthenticationSiteSettings';
|
||||
import createStoreHelper from '../../../helpers/createStore';
|
||||
import { State } from '../../../reducers/rootReducer';
|
||||
|
||||
interface Props {
|
||||
authenticityToken: string;
|
||||
}
|
||||
|
||||
class AuthenticationSiteSettingsRoot extends React.Component<Props> {
|
||||
store: Store<State, any>;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.store = createStoreHelper();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Provider store={this.store}>
|
||||
<AuthenticationSiteSettings
|
||||
authenticityToken={this.props.authenticityToken}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthenticationSiteSettingsRoot;
|
||||
@@ -67,7 +67,7 @@ const BoardForm = ({
|
||||
<input
|
||||
{...register('name', { required: true })}
|
||||
placeholder={I18n.t('site_settings.boards.form.name')}
|
||||
autoFocus
|
||||
autoFocus={mode === 'update'}
|
||||
className="formControl"
|
||||
/>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TENANT_BRAND_NONE,
|
||||
} from '../../../interfaces/ITenant';
|
||||
import { DangerText } from '../../common/CustomTexts';
|
||||
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
|
||||
|
||||
export interface ISiteSettingsGeneralForm {
|
||||
siteName: string;
|
||||
@@ -79,17 +80,17 @@ const GeneralSiteSettingsP = ({
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="formRow">
|
||||
<div className="formGroup col-4">
|
||||
<label htmlFor="siteName">{ I18n.t('site_settings.general.site_name') }</label>
|
||||
<label htmlFor="siteName">{ getLabel('tenant', 'site_name') }</label>
|
||||
<input
|
||||
{...register('siteName', { required: true })}
|
||||
id="siteName"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.siteName && I18n.t('site_settings.general.validations.site_name')}</DangerText>
|
||||
<DangerText>{errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-4">
|
||||
<label htmlFor="siteLogo">{ I18n.t('site_settings.general.site_logo') }</label>
|
||||
<label htmlFor="siteLogo">{ getLabel('tenant', 'site_logo') }</label>
|
||||
<input
|
||||
{...register('siteLogo')}
|
||||
id="siteLogo"
|
||||
@@ -98,7 +99,7 @@ const GeneralSiteSettingsP = ({
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-4">
|
||||
<label htmlFor="brandSetting">{ I18n.t('site_settings.general.brand_setting') }</label>
|
||||
<label htmlFor="brandSetting">{ getLabel('tenant', 'brand_setting') }</label>
|
||||
<select
|
||||
{...register('brandDisplaySetting')}
|
||||
id="brandSetting"
|
||||
@@ -121,7 +122,7 @@ const GeneralSiteSettingsP = ({
|
||||
</div>
|
||||
|
||||
<div className="formGroup">
|
||||
<label htmlFor="locale">{ I18n.t('site_settings.general.locale') }</label>
|
||||
<label htmlFor="locale">{ getLabel('tenant', 'locale') }</label>
|
||||
<select
|
||||
{...register('locale')}
|
||||
id="locale"
|
||||
|
||||
@@ -71,7 +71,7 @@ const PostStatusForm = ({
|
||||
<input
|
||||
{...register('name', { required: true })}
|
||||
placeholder={I18n.t('site_settings.post_statuses.form.name')}
|
||||
autoFocus
|
||||
autoFocus={mode === 'update'}
|
||||
className="formControl"
|
||||
/>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
|
||||
import { UsersState } from '../../../reducers/usersReducer';
|
||||
import { UserRoles, USER_STATUS_ACTIVE, USER_STATUS_BLOCKED } from '../../../interfaces/IUser';
|
||||
import HttpStatus from '../../../constants/http_status';
|
||||
import Spinner from '../../common/Spinner';
|
||||
|
||||
interface Props {
|
||||
users: UsersState;
|
||||
@@ -79,17 +80,20 @@ class UsersSiteSettingsP extends React.Component<Props> {
|
||||
|
||||
<ul className="usersList">
|
||||
{
|
||||
users.items.map((user, i) => (
|
||||
<UserEditable
|
||||
user={user}
|
||||
updateUserRole={this._handleUpdateUserRole}
|
||||
updateUserStatus={this._handleUpdateUserStatus}
|
||||
users.areLoading === false ?
|
||||
users.items.map((user, i) => (
|
||||
<UserEditable
|
||||
user={user}
|
||||
updateUserRole={this._handleUpdateUserRole}
|
||||
updateUserStatus={this._handleUpdateUserStatus}
|
||||
|
||||
currentUserEmail={currentUserEmail}
|
||||
currentUserRole={currentUserRole}
|
||||
key={i}
|
||||
/>
|
||||
))
|
||||
currentUserEmail={currentUserEmail}
|
||||
currentUserRole={currentUserRole}
|
||||
key={i}
|
||||
/>
|
||||
))
|
||||
:
|
||||
<Spinner />
|
||||
}
|
||||
</ul>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Spinner from '../common/Spinner';
|
||||
import { DangerText } from '../common/CustomTexts';
|
||||
import { ITenantSignUpTenantForm } from './TenantSignUpP';
|
||||
import HttpStatus from '../../constants/http_status';
|
||||
import { getLabel, getValidationMessage } from '../../helpers/formUtils';
|
||||
|
||||
interface Props {
|
||||
isSubmitting: boolean;
|
||||
@@ -34,11 +35,11 @@ const TenantSignUpForm = ({
|
||||
<input
|
||||
{...register('siteName', { required: true })}
|
||||
autoFocus
|
||||
placeholder={I18n.t('signup.step2.site_name')}
|
||||
placeholder={getLabel('tenant', 'site_name')}
|
||||
id="tenantSiteName"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{errors.siteName && I18n.t('signup.step2.validations.site_name')}</DangerText>
|
||||
<DangerText>{errors.siteName?.type === 'required' && getValidationMessage('required', 'tenant', 'site_name')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formRow">
|
||||
@@ -51,7 +52,7 @@ const TenantSignUpForm = ({
|
||||
return res.status === HttpStatus.OK;
|
||||
},
|
||||
})}
|
||||
placeholder={I18n.t('signup.step2.subdomain')}
|
||||
placeholder={getLabel('tenant', 'subdomain')}
|
||||
id="tenantSubdomain"
|
||||
className="formControl"
|
||||
/>
|
||||
@@ -60,7 +61,7 @@ const TenantSignUpForm = ({
|
||||
</div>
|
||||
</div>
|
||||
<DangerText>
|
||||
{errors.subdomain?.type === 'required' && I18n.t('signup.step2.validations.subdomain')}
|
||||
{errors.subdomain?.type === 'required' && getValidationMessage('required', 'tenant', 'subdomain')}
|
||||
</DangerText>
|
||||
<DangerText>
|
||||
{errors.subdomain?.type === 'validate' && I18n.t('signup.step2.validations.subdomain_already_taken')}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Box from '../common/Box';
|
||||
import Button from '../common/Button';
|
||||
import { ITenantSignUpUserForm } from './TenantSignUpP';
|
||||
import { DangerText } from '../common/CustomTexts';
|
||||
import { getLabel, getValidationMessage } from '../../helpers/formUtils';
|
||||
import { EMAIL_REGEX } from '../../constants/regex';
|
||||
|
||||
interface Props {
|
||||
currentStep: number;
|
||||
@@ -24,7 +26,13 @@ const UserSignUpForm = ({
|
||||
userData,
|
||||
setUserData,
|
||||
}: Props) => {
|
||||
const { register, handleSubmit, setError, formState: { errors } } = useForm<ITenantSignUpUserForm>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
getValues,
|
||||
formState: { errors }
|
||||
} = useForm<ITenantSignUpUserForm>();
|
||||
const onSubmit: SubmitHandler<ITenantSignUpUserForm> = data => {
|
||||
if (data.password !== data.passwordConfirmation) {
|
||||
setError('passwordConfirmation', I18n.t('signup.step1.validations.password_mismatch'));
|
||||
@@ -53,22 +61,25 @@ const UserSignUpForm = ({
|
||||
<input
|
||||
{...register('fullName', { required: true, minLength: 2 })}
|
||||
autoFocus
|
||||
placeholder={I18n.t('common.forms.auth.full_name')}
|
||||
placeholder={getLabel('user', 'full_name')}
|
||||
id="userFullName"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{ errors.fullName && I18n.t('signup.step1.validations.full_name') }</DangerText>
|
||||
<DangerText>{errors.fullName && getValidationMessage('required', 'user', 'full_name')}</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formRow">
|
||||
<input
|
||||
{...register('email', { required: true, pattern: /(.+)@(.+){2,}\.(.+){2,}/ })}
|
||||
{...register('email', { required: true, pattern: EMAIL_REGEX })}
|
||||
type="email"
|
||||
placeholder={I18n.t('common.forms.auth.email')}
|
||||
placeholder={getLabel('user', 'email')}
|
||||
id="userEmail"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{ errors.email && I18n.t('signup.step1.validations.email') }</DangerText>
|
||||
<DangerText>{errors.email?.type === 'required' && getValidationMessage('required', 'user', 'email')}</DangerText>
|
||||
<DangerText>
|
||||
{errors.email?.type === 'pattern' && I18n.t('common.validations.email')}
|
||||
</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formRow">
|
||||
@@ -76,22 +87,22 @@ const UserSignUpForm = ({
|
||||
<input
|
||||
{...register('password', { required: true, minLength: 6, maxLength: 128 })}
|
||||
type="password"
|
||||
placeholder={I18n.t('common.forms.auth.password')}
|
||||
placeholder={getLabel('user', 'password')}
|
||||
id="userPassword"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{ errors.password && I18n.t('signup.step1.validations.password', { n: 6 }) }</DangerText>
|
||||
<DangerText>{ errors.password && I18n.t('common.validations.password', { n: 6 }) }</DangerText>
|
||||
</div>
|
||||
|
||||
<div className="formGroup col-6">
|
||||
<input
|
||||
{...register('passwordConfirmation')}
|
||||
type="password"
|
||||
placeholder={I18n.t('common.forms.auth.password_confirmation')}
|
||||
placeholder={getLabel('user', 'password_confirmation')}
|
||||
id="userPasswordConfirmation"
|
||||
className="formControl"
|
||||
/>
|
||||
<DangerText>{ errors.passwordConfirmation && I18n.t('signup.step1.validations.password_mismatch') }</DangerText>
|
||||
<DangerText>{ errors.passwordConfirmation && I18n.t('common.validations.password_mismatch') }</DangerText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
43
app/javascript/components/common/CopyToClipboardButton.tsx
Normal file
43
app/javascript/components/common/CopyToClipboardButton.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
textToCopy: string;
|
||||
copiedLabel?: string;
|
||||
}
|
||||
|
||||
const CopyToClipboardButton = ({
|
||||
label,
|
||||
textToCopy,
|
||||
copiedLabel = I18n.t('common.copied')
|
||||
}: Props) => {
|
||||
const [ready, setReady] = useState(true);
|
||||
|
||||
const alertError = () =>
|
||||
alert(`Error in automatically copying to clipboard. Please copy the callback url manually:\n\n${textToCopy}`);
|
||||
|
||||
return (
|
||||
ready ?
|
||||
<a
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
setReady(false);
|
||||
setTimeout(() => setReady(true), 2000);
|
||||
},
|
||||
alertError);
|
||||
} else {
|
||||
alertError();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
:
|
||||
<span>{copiedLabel}</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyToClipboardButton;
|
||||
2
app/javascript/constants/regex.ts
Normal file
2
app/javascript/constants/regex.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/;
|
||||
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
|
||||
44
app/javascript/containers/AuthenticationSiteSettings.tsx
Normal file
44
app/javascript/containers/AuthenticationSiteSettings.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { connect } from "react-redux";
|
||||
import { deleteOAuth } from "../actions/OAuth/deleteOAuth";
|
||||
import { requestOAuths } from "../actions/OAuth/requestOAuths";
|
||||
import { submitOAuth } from "../actions/OAuth/submitOAuth";
|
||||
import { updateOAuth } from "../actions/OAuth/updateOAuth";
|
||||
|
||||
import AuthenticationSiteSettingsP from "../components/SiteSettings/Authentication/AuthenticationSiteSettingsP";
|
||||
import { ISiteSettingsOAuthForm } from "../components/SiteSettings/Authentication/OAuthForm";
|
||||
import { IOAuth } from "../interfaces/IOAuth";
|
||||
import { State } from "../reducers/rootReducer";
|
||||
|
||||
const mapStateToProps = (state: State) => ({
|
||||
oAuths: state.oAuths,
|
||||
|
||||
isSubmitting: state.siteSettings.authentication.isSubmitting,
|
||||
submitError: state.siteSettings.authentication.error,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => ({
|
||||
requestOAuths() {
|
||||
dispatch(requestOAuths());
|
||||
},
|
||||
|
||||
onSubmitOAuth(oAuth: IOAuth, authenticityToken: string): Promise<any> {
|
||||
return dispatch(submitOAuth(oAuth, authenticityToken));
|
||||
},
|
||||
|
||||
onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise<any> {
|
||||
return dispatch(updateOAuth({id, form, authenticityToken}));
|
||||
},
|
||||
|
||||
onToggleEnabledOAuth(id: number, isEnabled: boolean, authenticityToken: string) {
|
||||
dispatch(updateOAuth({id, isEnabled, authenticityToken}));
|
||||
},
|
||||
|
||||
onDeleteOAuth(id: number, authenticityToken: string) {
|
||||
dispatch(deleteOAuth(id, authenticityToken));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(AuthenticationSiteSettingsP);
|
||||
20
app/javascript/helpers/formUtils.ts
Normal file
20
app/javascript/helpers/formUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { FieldError } from "react-hook-form";
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
export const getLabel = (
|
||||
entity: string,
|
||||
attribute: string,
|
||||
) => (
|
||||
I18n.t(`activerecord.attributes.${entity}.${attribute}`)
|
||||
);
|
||||
|
||||
export const getValidationMessage = (
|
||||
validationType: FieldError['type'],
|
||||
entity: string,
|
||||
attribute: string,
|
||||
) => (
|
||||
I18n.t(
|
||||
`common.validations.${validationType}`,
|
||||
{ attribute: getLabel(entity, attribute) }
|
||||
)
|
||||
);
|
||||
67
app/javascript/interfaces/IOAuth.ts
Normal file
67
app/javascript/interfaces/IOAuth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface IOAuth {
|
||||
id?: number;
|
||||
name: string;
|
||||
logo?: string;
|
||||
isEnabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
profileUrl: string;
|
||||
scope: string;
|
||||
jsonUserEmailPath: string;
|
||||
jsonUserNamePath?: string;
|
||||
|
||||
callbackUrl?: string;
|
||||
}
|
||||
|
||||
export interface IOAuthJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
is_enabled: boolean;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
authorize_url: string;
|
||||
token_url: string;
|
||||
profile_url: string;
|
||||
scope: string;
|
||||
json_user_email_path: string;
|
||||
json_user_name_path?: string;
|
||||
|
||||
callback_url?: string;
|
||||
}
|
||||
|
||||
export const oAuthJSON2JS = (oAuthJSON: IOAuthJSON) => ({
|
||||
id: parseInt(oAuthJSON.id),
|
||||
name: oAuthJSON.name,
|
||||
logo: oAuthJSON.logo,
|
||||
isEnabled: oAuthJSON.is_enabled,
|
||||
clientId: oAuthJSON.client_id,
|
||||
clientSecret: oAuthJSON.client_secret,
|
||||
authorizeUrl: oAuthJSON.authorize_url,
|
||||
tokenUrl: oAuthJSON.token_url,
|
||||
scope: oAuthJSON.scope,
|
||||
profileUrl: oAuthJSON.profile_url,
|
||||
jsonUserEmailPath: oAuthJSON.json_user_email_path,
|
||||
jsonUserNamePath: oAuthJSON.json_user_name_path,
|
||||
|
||||
callbackUrl: oAuthJSON.callback_url,
|
||||
});
|
||||
|
||||
export const oAuthJS2JSON = (oAuth: IOAuth) => ({
|
||||
id: oAuth.id?.toString(),
|
||||
name: oAuth.name,
|
||||
logo: oAuth.logo,
|
||||
is_enabled: oAuth.isEnabled,
|
||||
client_id: oAuth.clientId,
|
||||
client_secret: oAuth.clientSecret,
|
||||
authorize_url: oAuth.authorizeUrl,
|
||||
token_url: oAuth.tokenUrl,
|
||||
profile_url: oAuth.profileUrl,
|
||||
scope: oAuth.scope,
|
||||
json_user_email_path: oAuth.jsonUserEmailPath,
|
||||
json_user_name_path: oAuth.jsonUserNamePath,
|
||||
|
||||
callback_url: oAuth.callbackUrl,
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
OAuthSubmitActionTypes,
|
||||
OAUTH_SUBMIT_START,
|
||||
OAUTH_SUBMIT_SUCCESS,
|
||||
OAUTH_SUBMIT_FAILURE,
|
||||
} from '../../actions/OAuth/submitOAuth';
|
||||
|
||||
import {
|
||||
OAuthUpdateActionTypes,
|
||||
OAUTH_UPDATE_START,
|
||||
OAUTH_UPDATE_SUCCESS,
|
||||
OAUTH_UPDATE_FAILURE,
|
||||
} from '../../actions/OAuth/updateOAuth';
|
||||
|
||||
import {
|
||||
OAuthDeleteActionTypes,
|
||||
OAUTH_DELETE_START,
|
||||
OAUTH_DELETE_SUCCESS,
|
||||
OAUTH_DELETE_FAILURE,
|
||||
} from '../../actions/OAuth/deleteOAuth';
|
||||
|
||||
export interface SiteSettingsAuthenticationState {
|
||||
isSubmitting: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const initialState: SiteSettingsAuthenticationState = {
|
||||
isSubmitting: false,
|
||||
error: '',
|
||||
};
|
||||
|
||||
const siteSettingsAuthenticationReducer = (
|
||||
state = initialState,
|
||||
action:
|
||||
OAuthSubmitActionTypes |
|
||||
OAuthUpdateActionTypes |
|
||||
OAuthDeleteActionTypes,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case OAUTH_SUBMIT_START:
|
||||
case OAUTH_UPDATE_START:
|
||||
case OAUTH_DELETE_START:
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: true,
|
||||
};
|
||||
|
||||
case OAUTH_SUBMIT_SUCCESS:
|
||||
case OAUTH_UPDATE_SUCCESS:
|
||||
case OAUTH_DELETE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: false,
|
||||
error: '',
|
||||
};
|
||||
|
||||
case OAUTH_SUBMIT_FAILURE:
|
||||
case OAUTH_UPDATE_FAILURE:
|
||||
case OAUTH_DELETE_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: false,
|
||||
error: action.error,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default siteSettingsAuthenticationReducer;
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
BoardsRequestActionTypes,
|
||||
BOARDS_REQUEST_START,
|
||||
BOARDS_REQUEST_SUCCESS,
|
||||
BOARDS_REQUEST_FAILURE,
|
||||
} from '../../actions/Board/requestBoards';
|
||||
|
||||
import {
|
||||
BoardSubmitActionTypes,
|
||||
BOARD_SUBMIT_START,
|
||||
@@ -46,14 +39,12 @@ const initialState: SiteSettingsBoardsState = {
|
||||
const siteSettingsBoardsReducer = (
|
||||
state = initialState,
|
||||
action:
|
||||
BoardsRequestActionTypes |
|
||||
BoardSubmitActionTypes |
|
||||
BoardUpdateActionTypes |
|
||||
BoardOrderUpdateActionTypes |
|
||||
BoardDeleteActionTypes
|
||||
): SiteSettingsBoardsState => {
|
||||
switch (action.type) {
|
||||
case BOARDS_REQUEST_START:
|
||||
case BOARD_SUBMIT_START:
|
||||
case BOARD_UPDATE_START:
|
||||
case BOARD_ORDER_UPDATE_START:
|
||||
@@ -63,7 +54,6 @@ const siteSettingsBoardsReducer = (
|
||||
areUpdating: true,
|
||||
};
|
||||
|
||||
case BOARDS_REQUEST_SUCCESS:
|
||||
case BOARD_SUBMIT_SUCCESS:
|
||||
case BOARD_UPDATE_SUCCESS:
|
||||
case BOARD_ORDER_UPDATE_SUCCESS:
|
||||
@@ -74,7 +64,6 @@ const siteSettingsBoardsReducer = (
|
||||
error: '',
|
||||
};
|
||||
|
||||
case BOARDS_REQUEST_FAILURE:
|
||||
case BOARD_SUBMIT_FAILURE:
|
||||
case BOARD_UPDATE_FAILURE:
|
||||
case BOARD_ORDER_UPDATE_FAILURE:
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
TENANT_UPDATE_FAILURE,
|
||||
} from '../../actions/Tenant/updateTenant';
|
||||
|
||||
|
||||
export interface SiteSettingsGeneralState {
|
||||
areUpdating: boolean;
|
||||
error: string;
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
PostStatusesRequestActionTypes,
|
||||
POST_STATUSES_REQUEST_START,
|
||||
POST_STATUSES_REQUEST_SUCCESS,
|
||||
POST_STATUSES_REQUEST_FAILURE,
|
||||
} from '../../actions/PostStatus/requestPostStatuses';
|
||||
|
||||
import {
|
||||
PostStatusOrderUpdateActionTypes,
|
||||
POSTSTATUS_ORDER_UPDATE_START,
|
||||
@@ -46,14 +39,12 @@ const initialState: SiteSettingsPostStatusesState = {
|
||||
const siteSettingsPostStatusesReducer = (
|
||||
state = initialState,
|
||||
action:
|
||||
PostStatusesRequestActionTypes |
|
||||
PostStatusOrderUpdateActionTypes |
|
||||
PostStatusDeleteActionTypes |
|
||||
PostStatusSubmitActionTypes |
|
||||
PostStatusUpdateActionTypes
|
||||
): SiteSettingsPostStatusesState => {
|
||||
switch (action.type) {
|
||||
case POST_STATUSES_REQUEST_START:
|
||||
case POSTSTATUS_SUBMIT_START:
|
||||
case POSTSTATUS_UPDATE_START:
|
||||
case POSTSTATUS_ORDER_UPDATE_START:
|
||||
@@ -63,7 +54,6 @@ const siteSettingsPostStatusesReducer = (
|
||||
areUpdating: true,
|
||||
};
|
||||
|
||||
case POST_STATUSES_REQUEST_SUCCESS:
|
||||
case POSTSTATUS_SUBMIT_SUCCESS:
|
||||
case POSTSTATUS_UPDATE_SUCCESS:
|
||||
case POSTSTATUS_ORDER_UPDATE_SUCCESS:
|
||||
@@ -74,7 +64,6 @@ const siteSettingsPostStatusesReducer = (
|
||||
error: '',
|
||||
};
|
||||
|
||||
case POST_STATUSES_REQUEST_FAILURE:
|
||||
case POSTSTATUS_SUBMIT_FAILURE:
|
||||
case POSTSTATUS_UPDATE_FAILURE:
|
||||
case POSTSTATUS_ORDER_UPDATE_FAILURE:
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
UsersRequestActionTypes,
|
||||
USERS_REQUEST_START,
|
||||
USERS_REQUEST_SUCCESS,
|
||||
USERS_REQUEST_FAILURE,
|
||||
} from '../../actions/User/requestUsers';
|
||||
|
||||
import {
|
||||
UserUpdateActionTypes,
|
||||
USER_UPDATE_START,
|
||||
@@ -24,17 +17,15 @@ const initialState: SiteSettingsUsersState = {
|
||||
|
||||
const siteSettingsUsersReducer = (
|
||||
state = initialState,
|
||||
action: UsersRequestActionTypes | UserUpdateActionTypes,
|
||||
action: UserUpdateActionTypes,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case USERS_REQUEST_START:
|
||||
case USER_UPDATE_START:
|
||||
return {
|
||||
...state,
|
||||
areUpdating: true,
|
||||
};
|
||||
|
||||
case USERS_REQUEST_SUCCESS:
|
||||
case USER_UPDATE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
@@ -42,7 +33,6 @@ const siteSettingsUsersReducer = (
|
||||
error: '',
|
||||
};
|
||||
|
||||
case USERS_REQUEST_FAILURE:
|
||||
case USER_UPDATE_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
|
||||
93
app/javascript/reducers/oAuthsReducer.ts
Normal file
93
app/javascript/reducers/oAuthsReducer.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
OAuthsRequestActionTypes,
|
||||
OAUTHS_REQUEST_START,
|
||||
OAUTHS_REQUEST_SUCCESS,
|
||||
OAUTHS_REQUEST_FAILURE,
|
||||
} from '../actions/OAuth/requestOAuths';
|
||||
|
||||
import {
|
||||
OAuthSubmitActionTypes,
|
||||
OAUTH_SUBMIT_SUCCESS,
|
||||
} from '../actions/OAuth/submitOAuth';
|
||||
|
||||
import {
|
||||
OAuthUpdateActionTypes,
|
||||
OAUTH_UPDATE_SUCCESS,
|
||||
} from '../actions/OAuth/updateOAuth';
|
||||
|
||||
import {
|
||||
OAuthDeleteActionTypes,
|
||||
OAUTH_DELETE_SUCCESS,
|
||||
} from '../actions/OAuth/deleteOAuth';
|
||||
|
||||
import { IOAuth, oAuthJSON2JS } from '../interfaces/IOAuth';
|
||||
|
||||
export interface OAuthsState {
|
||||
items: Array<IOAuth>;
|
||||
areLoading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const initialState: OAuthsState = {
|
||||
items: [],
|
||||
areLoading: false,
|
||||
error: '',
|
||||
};
|
||||
|
||||
const oAuthsReducer = (
|
||||
state = initialState,
|
||||
action:
|
||||
OAuthsRequestActionTypes |
|
||||
OAuthSubmitActionTypes |
|
||||
OAuthUpdateActionTypes |
|
||||
OAuthDeleteActionTypes,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case OAUTHS_REQUEST_START:
|
||||
return {
|
||||
...state,
|
||||
areLoading: true,
|
||||
};
|
||||
|
||||
case OAUTHS_REQUEST_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
areLoading: false,
|
||||
error: '',
|
||||
items: action.oAuths.map<IOAuth>(oAuthJson => oAuthJSON2JS(oAuthJson)),
|
||||
};
|
||||
|
||||
case OAUTHS_REQUEST_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
areLoading: false,
|
||||
error: action.error,
|
||||
};
|
||||
|
||||
case OAUTH_SUBMIT_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
items: [...state.items, oAuthJSON2JS(action.oAuth)],
|
||||
};
|
||||
|
||||
case OAUTH_UPDATE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map(oAuth => {
|
||||
if (oAuth.id !== parseInt(action.oAuth.id)) return oAuth;
|
||||
return oAuthJSON2JS(action.oAuth);
|
||||
})
|
||||
};
|
||||
|
||||
case OAUTH_DELETE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
items: state.items.filter(oAuth => oAuth.id !== action.id),
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default oAuthsReducer;
|
||||
@@ -8,6 +8,7 @@ import postStatusesReducer from './postStatusesReducer';
|
||||
import usersReducer from './usersReducer';
|
||||
import currentPostReducer from './currentPostReducer';
|
||||
import siteSettingsReducer from './siteSettingsReducer';
|
||||
import oAuthsReducer from './oAuthsReducer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
tenantSignUp: tenantSignUpReducer,
|
||||
@@ -18,6 +19,7 @@ const rootReducer = combineReducers({
|
||||
users: usersReducer,
|
||||
currentPost: currentPostReducer,
|
||||
siteSettings: siteSettingsReducer,
|
||||
oAuths: oAuthsReducer,
|
||||
});
|
||||
|
||||
export type State = ReturnType<typeof rootReducer>
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import {
|
||||
TenantRequestActionTypes,
|
||||
TENANT_REQUEST_START,
|
||||
TENANT_REQUEST_SUCCESS,
|
||||
TENANT_REQUEST_FAILURE,
|
||||
} from '../actions/Tenant/requestTenant';
|
||||
|
||||
import {
|
||||
TenantUpdateActionTypes,
|
||||
TENANT_UPDATE_START,
|
||||
@@ -12,13 +5,6 @@ import {
|
||||
TENANT_UPDATE_FAILURE,
|
||||
} from '../actions/Tenant/updateTenant';
|
||||
|
||||
import {
|
||||
BoardsRequestActionTypes,
|
||||
BOARDS_REQUEST_START,
|
||||
BOARDS_REQUEST_SUCCESS,
|
||||
BOARDS_REQUEST_FAILURE,
|
||||
} from '../actions/Board/requestBoards';
|
||||
|
||||
import {
|
||||
BoardSubmitActionTypes,
|
||||
BOARD_SUBMIT_START,
|
||||
@@ -47,13 +33,6 @@ import {
|
||||
BOARD_DELETE_FAILURE,
|
||||
} from '../actions/Board/deleteBoard';
|
||||
|
||||
import {
|
||||
PostStatusesRequestActionTypes,
|
||||
POST_STATUSES_REQUEST_START,
|
||||
POST_STATUSES_REQUEST_SUCCESS,
|
||||
POST_STATUSES_REQUEST_FAILURE,
|
||||
} from '../actions/PostStatus/requestPostStatuses';
|
||||
|
||||
import {
|
||||
PostStatusOrderUpdateActionTypes,
|
||||
POSTSTATUS_ORDER_UPDATE_START,
|
||||
@@ -82,13 +61,6 @@ import {
|
||||
POSTSTATUS_UPDATE_FAILURE,
|
||||
} from '../actions/PostStatus/updatePostStatus';
|
||||
|
||||
import {
|
||||
UsersRequestActionTypes,
|
||||
USERS_REQUEST_START,
|
||||
USERS_REQUEST_SUCCESS,
|
||||
USERS_REQUEST_FAILURE,
|
||||
} from '../actions/User/requestUsers';
|
||||
|
||||
import {
|
||||
UserUpdateActionTypes,
|
||||
USER_UPDATE_START,
|
||||
@@ -96,14 +68,37 @@ import {
|
||||
USER_UPDATE_FAILURE,
|
||||
} from '../actions/User/updateUser';
|
||||
|
||||
import {
|
||||
OAuthSubmitActionTypes,
|
||||
OAUTH_SUBMIT_START,
|
||||
OAUTH_SUBMIT_SUCCESS,
|
||||
OAUTH_SUBMIT_FAILURE,
|
||||
} from '../actions/OAuth/submitOAuth';
|
||||
|
||||
import {
|
||||
OAuthUpdateActionTypes,
|
||||
OAUTH_UPDATE_START,
|
||||
OAUTH_UPDATE_SUCCESS,
|
||||
OAUTH_UPDATE_FAILURE,
|
||||
} from '../actions/OAuth/updateOAuth';
|
||||
|
||||
import {
|
||||
OAuthDeleteActionTypes,
|
||||
OAUTH_DELETE_START,
|
||||
OAUTH_DELETE_SUCCESS,
|
||||
OAUTH_DELETE_FAILURE,
|
||||
} from '../actions/OAuth/deleteOAuth';
|
||||
|
||||
import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSettings/generalReducer';
|
||||
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
|
||||
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
|
||||
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
|
||||
import siteSettingsUsersReducer, { SiteSettingsUsersState } from './SiteSettings/usersReducer';
|
||||
import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer';
|
||||
|
||||
interface SiteSettingsState {
|
||||
general: SiteSettingsGeneralState;
|
||||
authentication: SiteSettingsAuthenticationState;
|
||||
boards: SiteSettingsBoardsState;
|
||||
postStatuses: SiteSettingsPostStatusesState;
|
||||
roadmap: SiteSettingsRoadmapState;
|
||||
@@ -112,6 +107,7 @@ interface SiteSettingsState {
|
||||
|
||||
const initialState: SiteSettingsState = {
|
||||
general: siteSettingsGeneralReducer(undefined, {} as any),
|
||||
authentication: siteSettingsAuthenticationReducer(undefined, {} as any),
|
||||
boards: siteSettingsBoardsReducer(undefined, {} as any),
|
||||
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
|
||||
roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
|
||||
@@ -121,19 +117,18 @@ const initialState: SiteSettingsState = {
|
||||
const siteSettingsReducer = (
|
||||
state = initialState,
|
||||
action:
|
||||
TenantRequestActionTypes |
|
||||
TenantUpdateActionTypes |
|
||||
BoardsRequestActionTypes |
|
||||
OAuthSubmitActionTypes |
|
||||
OAuthUpdateActionTypes |
|
||||
OAuthDeleteActionTypes |
|
||||
BoardSubmitActionTypes |
|
||||
BoardUpdateActionTypes |
|
||||
BoardOrderUpdateActionTypes |
|
||||
BoardDeleteActionTypes |
|
||||
PostStatusesRequestActionTypes |
|
||||
PostStatusOrderUpdateActionTypes |
|
||||
PostStatusDeleteActionTypes |
|
||||
PostStatusSubmitActionTypes |
|
||||
PostStatusUpdateActionTypes |
|
||||
UsersRequestActionTypes |
|
||||
UserUpdateActionTypes
|
||||
): SiteSettingsState => {
|
||||
switch (action.type) {
|
||||
@@ -144,10 +139,21 @@ const siteSettingsReducer = (
|
||||
...state,
|
||||
general: siteSettingsGeneralReducer(state.general, action),
|
||||
};
|
||||
|
||||
case OAUTH_SUBMIT_START:
|
||||
case OAUTH_SUBMIT_SUCCESS:
|
||||
case OAUTH_SUBMIT_FAILURE:
|
||||
case OAUTH_UPDATE_START:
|
||||
case OAUTH_UPDATE_SUCCESS:
|
||||
case OAUTH_UPDATE_FAILURE:
|
||||
case OAUTH_DELETE_START:
|
||||
case OAUTH_DELETE_SUCCESS:
|
||||
case OAUTH_DELETE_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
authentication: siteSettingsAuthenticationReducer(state.authentication, action),
|
||||
};
|
||||
|
||||
case BOARDS_REQUEST_START:
|
||||
case BOARDS_REQUEST_SUCCESS:
|
||||
case BOARDS_REQUEST_FAILURE:
|
||||
case BOARD_SUBMIT_START:
|
||||
case BOARD_SUBMIT_SUCCESS:
|
||||
case BOARD_SUBMIT_FAILURE:
|
||||
@@ -165,9 +171,6 @@ const siteSettingsReducer = (
|
||||
boards: siteSettingsBoardsReducer(state.boards, action),
|
||||
};
|
||||
|
||||
case POST_STATUSES_REQUEST_START:
|
||||
case POST_STATUSES_REQUEST_SUCCESS:
|
||||
case POST_STATUSES_REQUEST_FAILURE:
|
||||
case POSTSTATUS_SUBMIT_START:
|
||||
case POSTSTATUS_SUBMIT_SUCCESS:
|
||||
case POSTSTATUS_SUBMIT_FAILURE:
|
||||
@@ -191,9 +194,6 @@ const siteSettingsReducer = (
|
||||
roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
|
||||
};
|
||||
|
||||
case USERS_REQUEST_START:
|
||||
case USERS_REQUEST_SUCCESS:
|
||||
case USERS_REQUEST_FAILURE:
|
||||
case USER_UPDATE_START:
|
||||
case USER_UPDATE_SUCCESS:
|
||||
case USER_UPDATE_FAILURE:
|
||||
|
||||
@@ -182,4 +182,9 @@
|
||||
.selectPicker {
|
||||
@extend
|
||||
.custom-select;
|
||||
}
|
||||
|
||||
.link {
|
||||
cursor: pointer;
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.authenticationIndexPage {
|
||||
h2 { @extend .mb-3; }
|
||||
|
||||
.oauthProvidersTitle {
|
||||
@extend .d-flex;
|
||||
|
||||
button {
|
||||
@extend .ml-2;
|
||||
|
||||
height: min-content;
|
||||
}
|
||||
}
|
||||
|
||||
.oAuthsList {
|
||||
@extend .pl-1;
|
||||
|
||||
list-style: none;
|
||||
|
||||
.oAuthListItem {
|
||||
@extend
|
||||
.d-flex,
|
||||
.justify-content-between,
|
||||
.my-2,
|
||||
.p-3;
|
||||
|
||||
.oAuthInfo {
|
||||
@extend .d-flex;
|
||||
|
||||
column-gap: 32px;
|
||||
|
||||
.oAuthLogo { border-radius: 100%; align-self: center; }
|
||||
|
||||
.oAuthName { font-size: 18px; }
|
||||
}
|
||||
|
||||
.oAuthActions {
|
||||
align-self: center;
|
||||
|
||||
a {
|
||||
@extend .link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.authenticationFormPage {
|
||||
a.backButton {
|
||||
@extend .mb-2;
|
||||
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
a.link {
|
||||
@extend .link;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
@import 'components/SiteSettings/PostStatuses';
|
||||
@import 'components/SiteSettings/Roadmap';
|
||||
@import 'components/SiteSettings/Users';
|
||||
@import 'components/SiteSettings/Authentication';
|
||||
|
||||
/* Icons */
|
||||
@import 'icons/drag_icon';
|
||||
30
app/models/o_auth.rb
Normal file
30
app/models/o_auth.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
class OAuth < ApplicationRecord
|
||||
include TenantOwnable
|
||||
include ApplicationHelper
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
attr_accessor :state
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :tenant_id }
|
||||
validates :is_enabled, inclusion: { in: [true, false] }
|
||||
validates :client_id, presence: true
|
||||
validates :client_secret, presence: true
|
||||
validates :authorize_url, presence: true
|
||||
validates :token_url, presence: true
|
||||
validates :profile_url, presence: true
|
||||
validates :scope, presence: true
|
||||
validates :json_user_email_path, presence: true
|
||||
|
||||
def callback_url
|
||||
add_subdomain_to(method(:o_auth_callback_url), id)
|
||||
end
|
||||
|
||||
def authorize_url_with_query_params
|
||||
"#{authorize_url}?"\
|
||||
"response_type=code&"\
|
||||
"client_id=#{client_id}&"\
|
||||
"redirect_uri=#{callback_url()}&"\
|
||||
"scope=#{scope}&"\
|
||||
"state=#{state}"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Tenant < ApplicationRecord
|
||||
has_many :boards
|
||||
has_many :o_auths
|
||||
has_many :post_statuses
|
||||
has_many :posts
|
||||
has_many :users
|
||||
|
||||
37
app/policies/o_auth_policy.rb
Normal file
37
app/policies/o_auth_policy.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class OAuthPolicy < ApplicationPolicy
|
||||
def permitted_attributes
|
||||
if user.admin?
|
||||
[
|
||||
:name,
|
||||
:logo,
|
||||
:is_enabled,
|
||||
:client_id,
|
||||
:client_secret,
|
||||
:authorize_url,
|
||||
:token_url,
|
||||
:profile_url,
|
||||
:scope,
|
||||
:json_user_name_path,
|
||||
:json_user_email_path
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def index?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin? and user.tenant_id == record.tenant_id
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin? and user.tenant_id == record.tenant_id
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,17 @@
|
||||
<div class="actions">
|
||||
<%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<% if not @o_auths.empty? %>
|
||||
<% @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 %>
|
||||
|
||||
<%= render "devise/shared/links" %>
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
<div class="actions">
|
||||
<%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<% if not @o_auths.empty? %>
|
||||
<% @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" %>
|
||||
47
app/views/o_auths/test.html.erb
Normal file
47
app/views/o_auths/test.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= t('site_settings.authentication.test_page.title', { name: @o_auth.name }) %></title>
|
||||
</head>
|
||||
<body>
|
||||
<h1><%= t('site_settings.authentication.test_page.title', { name: @o_auth.name }) %></h1>
|
||||
|
||||
<div>
|
||||
<h2><%= t('site_settings.authentication.test_page.fetched_user_data') %></h2>
|
||||
|
||||
<pre><%= JSON.pretty_generate(@user_profile, { indent: " ", object_nl: "\n" }) %></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2><%= t('activerecord.attributes.user.email') %> <%= @email_valid ? "✅" : "❌" %></h2>
|
||||
<ul>
|
||||
<li><b><%= t('site_settings.authentication.form.jsonUserEmailPath') %>:</b> <%= @o_auth.json_user_email_path %></li>
|
||||
<li><b><%= t('site_settings.authentication.test_page.found') %>:</b> <%= @user_email %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2><%= t('activerecord.attributes.user.full_name') %> <%= @name_valid ? "✅" : "⚠️" %></h2>
|
||||
<ul>
|
||||
<li><b><%= t('site_settings.authentication.form.jsonUserNamePath') %>:</b> <%= @o_auth.json_user_name_path %></li>
|
||||
<li><b><%= t('site_settings.authentication.test_page.found') %>:</b> <%= @user_name %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2><%= t('site_settings.authentication.test_page.summary') %> <%= !@email_valid ? "❌" : !@name_valid ? "⚠️" : "✅" %></h2>
|
||||
|
||||
<% if @email_valid and @name_valid %>
|
||||
<p><%= t('site_settings.authentication.test_page.valid_configuration') %></p>
|
||||
<% end %>
|
||||
|
||||
<% if @email_valid and not @name_valid %>
|
||||
<p><%= t('site_settings.authentication.test_page.warning_configuration', { name: t('defaults.user_full_name') }) %></p>
|
||||
<% end %>
|
||||
|
||||
<% if not @email_valid %>
|
||||
<p><%= t('site_settings.authentication.test_page.invalid_configuration') %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="verticalNavigation" role="tablist" aria-orientation="vertical">
|
||||
<% if current_user.admin? %>
|
||||
<%= render 'menu_link', label: t('site_settings.menu.general'), path: site_settings_general_path %>
|
||||
<%= render 'menu_link', label: t('site_settings.menu.authentication'), path: site_settings_authentication_path %>
|
||||
<%= render 'menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
|
||||
<%= render 'menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
|
||||
<%= render 'menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>
|
||||
|
||||
13
app/views/site_settings/authentication.html.erb
Normal file
13
app/views/site_settings/authentication.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="twoColumnsContainer">
|
||||
<%= render 'menu' %>
|
||||
<div>
|
||||
<%=
|
||||
react_component(
|
||||
'SiteSettings/Authentication',
|
||||
{
|
||||
authenticityToken: form_authenticity_token
|
||||
}
|
||||
)
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
56
app/workflows/OAuthExchangeAuthCodeForProfile.rb
Normal file
56
app/workflows/OAuthExchangeAuthCodeForProfile.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class OAuthExchangeAuthCodeForProfile
|
||||
include HTTParty
|
||||
|
||||
attr_accessor :authorization_code, :o_auth
|
||||
|
||||
# Given:
|
||||
# authorization_code: code returned by OAuth provider on user confirmation
|
||||
# o_auth: ActiveRecord model with information about the OAuth provider
|
||||
#
|
||||
# The workfow first exchanges the authorization code for an access token
|
||||
# Then it uses the access token to fetch user profile information
|
||||
#
|
||||
# Returns:
|
||||
# The fetched user profile as a Hash, if successful
|
||||
# nil, if unsuccessful
|
||||
|
||||
def initialize(authorization_code: "", o_auth: "")
|
||||
@authorization_code = authorization_code
|
||||
@o_auth = o_auth
|
||||
end
|
||||
|
||||
def run
|
||||
return nil unless @o_auth and @o_auth.class == OAuth
|
||||
return nil unless @authorization_code and @authorization_code.class == String
|
||||
|
||||
begin
|
||||
# Exchange authorization code for access token
|
||||
token_request_params = {
|
||||
code: @authorization_code,
|
||||
client_id: @o_auth.client_id,
|
||||
client_secret: @o_auth.client_secret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: @o_auth.callback_url
|
||||
}
|
||||
|
||||
token_response = HTTParty.post(
|
||||
@o_auth.token_url,
|
||||
headers: { "Accept": "application/json" },
|
||||
body: token_request_params
|
||||
)
|
||||
access_token = token_response['access_token']
|
||||
|
||||
# Exchange access token for profile info
|
||||
profile_response = HTTParty.get(
|
||||
@o_auth.profile_url,
|
||||
headers: { "Authorization": "Bearer #{access_token}" },
|
||||
format: :json
|
||||
).parsed_response
|
||||
|
||||
return profile_response
|
||||
rescue => error
|
||||
print(error)
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
59
app/workflows/OAuthSignInUser.rb
Normal file
59
app/workflows/OAuthSignInUser.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class OAuthSignInUser
|
||||
include OAuthsHelper
|
||||
|
||||
attr_accessor :user_profile, :o_auth
|
||||
|
||||
# Given:
|
||||
# user_profile: ruby Hash containing information about the user
|
||||
# Could've been returned from OAuthExchangeAuthCodeForProfile
|
||||
# o_auth: ActiveRecord model with information about the OAuth provider
|
||||
#
|
||||
# The workfow creates a new user if it doesn't exist, or select the existing one
|
||||
# NOTE: it does NOT actually sign in the user, but rather returns it to the controller
|
||||
# where it'll be signed in
|
||||
#
|
||||
# Returns:
|
||||
# the user, if successful
|
||||
# nil, if unsuccessful
|
||||
|
||||
def initialize(user_profile: "", o_auth: "")
|
||||
@user_profile = user_profile
|
||||
@o_auth = o_auth
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
begin
|
||||
# Attempts to get email from user_profile Hash
|
||||
email = query_path_from_hash(@user_profile, @o_auth.json_user_email_path)
|
||||
|
||||
return nil if email.nil? or not URI::MailTo::EMAIL_REGEXP.match?(email)
|
||||
|
||||
# Select existing / create new user
|
||||
user = User.find_by(email: email)
|
||||
|
||||
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)
|
||||
end
|
||||
full_name ||= I18n.t('defaults.user_full_name')
|
||||
|
||||
user = User.new(
|
||||
email: email,
|
||||
full_name: full_name,
|
||||
password: Devise.friendly_token,
|
||||
status: 'active'
|
||||
)
|
||||
user.skip_confirmation!
|
||||
user.save
|
||||
end
|
||||
|
||||
return user
|
||||
rescue => error
|
||||
print(error)
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,10 @@ module App
|
||||
# -- all .rb files in that directory are automatically loaded after loading
|
||||
# the framework and any gems in your application.
|
||||
|
||||
def base_url
|
||||
ENV["BASE_URL"]
|
||||
end
|
||||
|
||||
def multi_tenancy?
|
||||
ENV["MULTI_TENANCY"] == "true"
|
||||
end
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# For subdomains in localhost
|
||||
config.hosts << ".localhost:3000"
|
||||
config.hosts << ".lvh.me:3000" # used to test oauth strategies in development
|
||||
|
||||
# 0 if using localhost, 1 if using lvh.me
|
||||
config.action_dispatch.tld_length = 0
|
||||
|
||||
# For Devise
|
||||
config.action_mailer.default_url_options = { host: 'localhost:3000' }
|
||||
config.action_mailer.default_url_options = { host: Rails.application.base_url }
|
||||
|
||||
# In the development environment your application's code is reloaded on
|
||||
# every request. This slows down response time but is perfect for development
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
en:
|
||||
errors:
|
||||
unknown: 'An unknown error occurred'
|
||||
unauthorized: 'You are not authorized'
|
||||
not_logged_in: 'You must be logged in to access this page'
|
||||
not_enough_privileges: 'You do not have the privilegies to access this page'
|
||||
user_blocked_or_deleted: 'You cannot access your account because it has been blocked or deleted.'
|
||||
o_auth_login_error: 'There was an error logging in with %{name}. Please contact the site administrator or try a different provider.'
|
||||
board:
|
||||
update_order: 'There was an error in reordering boards'
|
||||
post_status:
|
||||
update_order: 'There was an error in reordering statuses'
|
||||
defaults:
|
||||
user_full_name: 'Anonymous User'
|
||||
mailers:
|
||||
devise:
|
||||
welcome_greeting: 'Welcome to %{site_name}, %{email}!'
|
||||
@@ -60,6 +64,18 @@ en:
|
||||
like:
|
||||
user_id: 'User'
|
||||
post_id: 'Post'
|
||||
o_auth:
|
||||
name: 'Name'
|
||||
logo: 'Logo'
|
||||
is_enabled: 'Enabled'
|
||||
client_id: 'Client ID'
|
||||
client_secret: 'Client secret'
|
||||
authorize_url: 'Authorize URL'
|
||||
token_url: 'Token URL'
|
||||
profile_url: 'Profile URL'
|
||||
scope: 'Scope'
|
||||
json_user_name_path: 'JSON path to user name'
|
||||
json_user_email_path: 'JSON path to user email'
|
||||
post_status:
|
||||
name: 'Name'
|
||||
color: 'Color'
|
||||
@@ -71,9 +87,17 @@ en:
|
||||
board_id: 'Post board'
|
||||
user_id: 'Post author'
|
||||
post_status_id: 'Post status'
|
||||
tenant:
|
||||
site_name: 'Site name'
|
||||
site_logo: 'Site logo'
|
||||
subdomain: 'Subdomain'
|
||||
locale: 'Language'
|
||||
brand_setting: 'Display'
|
||||
user:
|
||||
email: 'Email'
|
||||
full_name: 'Name and surname'
|
||||
full_name: 'Full name'
|
||||
password: 'Password'
|
||||
password_confirmation: 'Password confirmation'
|
||||
role: 'Role'
|
||||
notifications_enabled: 'Notifications enabled'
|
||||
errors:
|
||||
@@ -83,4 +107,4 @@ en:
|
||||
blank: 'cannot be blank'
|
||||
taken: 'is already in use'
|
||||
too_short: 'is too short (minimum %{count} characters)'
|
||||
too_long: 'is too long (maximum ${count} characters)'
|
||||
too_long: 'is too long (maximum %{count} characters)'
|
||||
@@ -1,5 +1,11 @@
|
||||
en:
|
||||
common:
|
||||
validations:
|
||||
required: '%{attribute} is required'
|
||||
email: 'Invalid email'
|
||||
url: 'Invalid URL'
|
||||
password: 'Password must have at least %{n} characters'
|
||||
password_mismatch: 'Password and password confirmation must match'
|
||||
forms:
|
||||
auth:
|
||||
email: 'Email'
|
||||
@@ -14,6 +20,8 @@ en:
|
||||
remember_me: 'Remember me'
|
||||
log_in: 'Log in'
|
||||
sign_up: 'Sign up'
|
||||
log_in_with: 'Log in with %{o_auth}'
|
||||
sign_up_with: 'Sign up with %{o_auth}'
|
||||
profile_settings: 'Profile settings'
|
||||
update_profile: 'Update profile'
|
||||
cancel_account: 'Cancel account'
|
||||
@@ -31,14 +39,20 @@ en:
|
||||
no_status: 'No status'
|
||||
loading: 'Loading...'
|
||||
confirmation: 'Are you sure?'
|
||||
unsaved_changes: 'Unsaved changes will be lost if you leave the page.'
|
||||
edited: 'Edited'
|
||||
enabled: 'Enabled'
|
||||
disabled: 'Disabled'
|
||||
copied: 'Copied!'
|
||||
buttons:
|
||||
new: 'New'
|
||||
edit: 'Edit'
|
||||
delete: 'Delete'
|
||||
cancel: 'Cancel'
|
||||
create: 'Create'
|
||||
update: 'Save'
|
||||
confirm: 'Confirm'
|
||||
back: 'Back'
|
||||
datetime:
|
||||
now: 'just now'
|
||||
minutes:
|
||||
@@ -55,19 +69,10 @@ en:
|
||||
step1:
|
||||
title: '1. Create user account'
|
||||
email_auth: 'Register with email'
|
||||
validations:
|
||||
full_name: 'Full name is required'
|
||||
email: 'Email is invalid'
|
||||
password: 'Password must have at least %{n} characters'
|
||||
password_mismatch: 'Password and password confirmation must match'
|
||||
step2:
|
||||
title: '2. Create feedback space'
|
||||
site_name: 'Site name'
|
||||
subdomain: 'Subdomain'
|
||||
create_button: 'Create feedback space'
|
||||
validations:
|
||||
site_name: 'Site name is required'
|
||||
subdomain: 'Subdomain is required'
|
||||
subdomain_already_taken: 'Sorry, this subdomain is not available'
|
||||
step3:
|
||||
title: "You're almost done!"
|
||||
@@ -136,22 +141,17 @@ en:
|
||||
post_statuses: 'Statuses'
|
||||
roadmap: 'Roadmap'
|
||||
users: 'Users'
|
||||
authentication: 'Authentication'
|
||||
info_box:
|
||||
up_to_date: 'All changes saved'
|
||||
error: 'An error occurred: %{message}'
|
||||
dirty: 'Changes not saved'
|
||||
general:
|
||||
title: 'General'
|
||||
site_name: 'Site name'
|
||||
site_logo: 'Site logo'
|
||||
brand_setting: 'Display'
|
||||
brand_setting_both: 'Both name and logo'
|
||||
brand_setting_name: 'Name only'
|
||||
brand_setting_logo: 'Logo only'
|
||||
brand_setting_none: 'None'
|
||||
locale: 'Language'
|
||||
validations:
|
||||
site_name: 'Site name is required'
|
||||
boards:
|
||||
title: 'Boards'
|
||||
empty: 'There are no boards. Create one below!'
|
||||
@@ -182,4 +182,22 @@ en:
|
||||
role_admin: 'Administrator'
|
||||
status_active: 'Active'
|
||||
status_blocked: 'Blocked'
|
||||
status_deleted: 'Deleted'
|
||||
status_deleted: 'Deleted'
|
||||
authentication:
|
||||
title: 'Authentication'
|
||||
oauth_subtitle: 'OAuth providers'
|
||||
copy_url: 'Copy URL'
|
||||
test_page:
|
||||
title: '%{name} OAuth test results'
|
||||
fetched_user_data: 'Fetched user data'
|
||||
found: 'Found'
|
||||
summary: 'Summary'
|
||||
valid_configuration: 'This OAuth provider is configured correctly!'
|
||||
warning_configuration: 'This OAuth provider if configured correctly, but a default name for users (%{name}) will be used.'
|
||||
invalid_configuration: 'This OAuth provider is NOT configured correctly.'
|
||||
form:
|
||||
title_new: 'New OAuth provider'
|
||||
title_edit: 'Edit OAuth provider'
|
||||
subtitle_oauth_config: 'OAuth configuration'
|
||||
subtitle_user_profile_config: 'User profile configuration'
|
||||
client_secret_help: 'hidden for security purposes'
|
||||
@@ -26,6 +26,9 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :tenants, only: [:show, :update]
|
||||
resources :users, only: [:index, :update]
|
||||
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
|
||||
|
||||
resources :posts, only: [:index, :create, :show, :update, :destroy] do
|
||||
resource :follows, only: [:create, :destroy]
|
||||
@@ -50,6 +53,7 @@ Rails.application.routes.draw do
|
||||
get 'post_statuses'
|
||||
get 'roadmap'
|
||||
get 'users'
|
||||
get 'authentication'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
22
db/migrate/20220727090932_create_o_auths.rb
Normal file
22
db/migrate/20220727090932_create_o_auths.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CreateOAuths < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
create_table :o_auths do |t|
|
||||
t.string :name, null: false
|
||||
t.string :logo
|
||||
t.boolean :is_enabled, default: false
|
||||
t.string :client_id, null: false
|
||||
t.string :client_secret, null: false
|
||||
t.string :authorize_url, null: false
|
||||
t.string :token_url, null: false
|
||||
t.string :profile_url, null: false
|
||||
t.string :scope, null: false
|
||||
t.string :json_user_name_path
|
||||
t.string :json_user_email_path, null: false
|
||||
t.references :tenant, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :o_auths, [:name, :tenant_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddNotNullToOAuthIsEnabled < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
change_column_null :o_auths, :is_enabled, false
|
||||
end
|
||||
end
|
||||
22
db/schema.rb
22
db/schema.rb
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_07_15_092725) do
|
||||
ActiveRecord::Schema.define(version: 2022_07_27_094200) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
@@ -65,6 +65,25 @@ ActiveRecord::Schema.define(version: 2022_07_15_092725) do
|
||||
t.index ["user_id"], name: "index_likes_on_user_id"
|
||||
end
|
||||
|
||||
create_table "o_auths", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "logo"
|
||||
t.boolean "is_enabled", default: false, null: false
|
||||
t.string "client_id", null: false
|
||||
t.string "client_secret", null: false
|
||||
t.string "authorize_url", null: false
|
||||
t.string "token_url", null: false
|
||||
t.string "profile_url", null: false
|
||||
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.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
|
||||
t.index ["tenant_id"], name: "index_o_auths_on_tenant_id"
|
||||
end
|
||||
|
||||
create_table "post_status_changes", force: :cascade do |t|
|
||||
t.bigint "user_id", null: false
|
||||
t.bigint "post_id", null: false
|
||||
@@ -151,6 +170,7 @@ ActiveRecord::Schema.define(version: 2022_07_15_092725) do
|
||||
add_foreign_key "likes", "posts"
|
||||
add_foreign_key "likes", "tenants"
|
||||
add_foreign_key "likes", "users"
|
||||
add_foreign_key "o_auths", "tenants"
|
||||
add_foreign_key "post_status_changes", "post_statuses"
|
||||
add_foreign_key "post_status_changes", "posts"
|
||||
add_foreign_key "post_status_changes", "tenants"
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
dockerfile: ./docker/app/Dockerfile
|
||||
environment:
|
||||
- UPDATE=0
|
||||
- BASE_URL
|
||||
- ENVIRONMENT
|
||||
- SECRET_KEY_BASE
|
||||
- POSTGRES_USER
|
||||
|
||||
15
spec/factories/o_auths.rb
Normal file
15
spec/factories/o_auths.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
FactoryBot.define do
|
||||
factory :o_auth do
|
||||
sequence(:name) { |n| "OAuth#{n}" }
|
||||
logo { "url_to_logo" }
|
||||
is_enabled { false }
|
||||
client_id { "123456" }
|
||||
client_secret { "123456" }
|
||||
authorize_url { "authorize_url" }
|
||||
token_url { "token_url" }
|
||||
profile_url { "profile_url" }
|
||||
scope { "read" }
|
||||
json_user_name_path { "user.name" }
|
||||
json_user_email_path { "user.email" }
|
||||
end
|
||||
end
|
||||
53
spec/helpers/o_auths_helper_spec.rb
Normal file
53
spec/helpers/o_auths_helper_spec.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe OAuthsHelper, type: :helper do
|
||||
context 'query_path_from_hash method' do
|
||||
it 'queries a path from hash' do
|
||||
email = "admin@example.com"
|
||||
name = "Admin"
|
||||
surname = "Example"
|
||||
hash = {
|
||||
"email" => email,
|
||||
"info" => {
|
||||
"name" => name,
|
||||
"additional_info" => {
|
||||
"surnames" => [
|
||||
["surname" => "Surname1"],
|
||||
["surname" => "Surname2"],
|
||||
["surname" => surname]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
email_path = "email"
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'returns nil if path not found' do
|
||||
email = "admin@example.com"
|
||||
name = "Admin"
|
||||
hash = {
|
||||
"email" => email,
|
||||
"info" => {
|
||||
"name" => name,
|
||||
}
|
||||
}
|
||||
|
||||
name_path = "name"
|
||||
expect(helper.query_path_from_hash(hash, name_path)).to eq(nil)
|
||||
|
||||
name_path = "info.names[0]"
|
||||
expect(helper.query_path_from_hash(hash, name_path)).to eq(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
64
spec/models/o_auth_spec.rb
Normal file
64
spec/models/o_auth_spec.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe OAuth, type: :model do
|
||||
let(:o_auth) { FactoryBot.create(:o_auth) }
|
||||
|
||||
it 'should be valid' do
|
||||
expect(o_auth).to be_valid
|
||||
end
|
||||
|
||||
it 'has a non-nil unique name' do
|
||||
o_auth2 = FactoryBot.build_stubbed(:o_auth, name: o_auth.name)
|
||||
|
||||
expect(o_auth2).to be_invalid
|
||||
end
|
||||
|
||||
it 'is disabled by default' do
|
||||
o_auth = OAuth.new
|
||||
|
||||
expect(o_auth.is_enabled).to eq(false)
|
||||
end
|
||||
|
||||
it 'has a boolean enabled status' do
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, is_enabled: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, is_enabled: true)
|
||||
expect(o_auth).to be_valid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, is_enabled: false)
|
||||
expect(o_auth).to be_valid
|
||||
end
|
||||
|
||||
it 'has non-nil client credentials' do
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, client_id: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, client_secret: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
end
|
||||
|
||||
it 'has non-nil urls' do
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, authorize_url: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, token_url: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, profile_url: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
end
|
||||
|
||||
it 'has a non-nil scope' do
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, scope: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
end
|
||||
|
||||
it 'has a non-nil json user email path and a nullable name path' do
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, json_user_email_path: nil)
|
||||
expect(o_auth).to be_invalid
|
||||
|
||||
o_auth = FactoryBot.build_stubbed(:o_auth, json_user_name_path: nil)
|
||||
expect(o_auth).to be_valid
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user