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:
Riccardo Graziosi
2022-08-05 18:15:17 +02:00
committed by GitHub
parent 3bda6dee08
commit 4c73b398e8
65 changed files with 2096 additions and 129 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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));
}
}
);

View 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));
}
}
)

View 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);
}
};

View 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);
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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"
/>

View File

@@ -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,6 +80,7 @@ class UsersSiteSettingsP extends React.Component<Props> {
<ul className="usersList">
{
users.areLoading === false ?
users.items.map((user, i) => (
<UserEditable
user={user}
@@ -90,6 +92,8 @@ class UsersSiteSettingsP extends React.Component<Props> {
key={i}
/>
))
:
<Spinner />
}
</ul>
</Box>

View File

@@ -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')}

View File

@@ -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>

View 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;

View File

@@ -0,0 +1,2 @@
export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;

View 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);

View 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) }
)
);

View 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,
});

View File

@@ -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;

View File

@@ -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:

View File

@@ -5,7 +5,6 @@ import {
TENANT_UPDATE_FAILURE,
} from '../../actions/Tenant/updateTenant';
export interface SiteSettingsGeneralState {
areUpdating: boolean;
error: string;

View File

@@ -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:

View File

@@ -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,

View 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;

View File

@@ -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>

View File

@@ -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) {
@@ -145,9 +140,20 @@ const siteSettingsReducer = (
general: siteSettingsGeneralReducer(state.general, action),
};
case BOARDS_REQUEST_START:
case BOARDS_REQUEST_SUCCESS:
case BOARDS_REQUEST_FAILURE:
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 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:

View File

@@ -183,3 +183,8 @@
@extend
.custom-select;
}
.link {
cursor: pointer;
&:hover { text-decoration: underline; }
}

View File

@@ -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;
}
}

View File

@@ -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
View 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

View File

@@ -1,5 +1,6 @@
class Tenant < ApplicationRecord
has_many :boards
has_many :o_auths
has_many :post_statuses
has_many :posts
has_many :users

View 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

View File

@@ -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" %>

View File

@@ -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" %>

View 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>

View File

@@ -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 %>

View File

@@ -0,0 +1,13 @@
<div class="twoColumnsContainer">
<%= render 'menu' %>
<div>
<%=
react_component(
'SiteSettings/Authentication',
{
authenticityToken: form_authenticity_token
}
)
%>
</div>
</div>

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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)'

View File

@@ -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!'
@@ -183,3 +183,21 @@ en:
status_active: 'Active'
status_blocked: 'Blocked'
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'

View File

@@ -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

View 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

View File

@@ -0,0 +1,5 @@
class AddNotNullToOAuthIsEnabled < ActiveRecord::Migration[6.0]
def change
change_column_null :o_auths, :is_enabled, false
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 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"

View File

@@ -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
View 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

View 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

View 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