diff --git a/.env-example b/.env-example index 24f6b18e..10369472 100644 --- a/.env-example +++ b/.env-example @@ -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 diff --git a/Gemfile b/Gemfile index 718137ec..4ff7851b 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 0ba312b5..714195e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3d755abf..bb4af517 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/o_auths_controller.rb b/app/controllers/o_auths_controller.rb new file mode 100644 index 00000000..879c1866 --- /dev/null +++ b/app/controllers/o_auths_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 3ad9264e..1d94c229 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 876c25fc..8752c7c5 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb index 0e14b772..6cbdbee4 100644 --- a/app/controllers/site_settings_controller.rb +++ b/app/controllers/site_settings_controller.rb @@ -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 \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 18b0d32d..d4e8b9f1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/helpers/o_auths_helper.rb b/app/helpers/o_auths_helper.rb new file mode 100644 index 00000000..d21ed33b --- /dev/null +++ b/app/helpers/o_auths_helper.rb @@ -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 \ No newline at end of file diff --git a/app/javascript/actions/OAuth/deleteOAuth.ts b/app/javascript/actions/OAuth/deleteOAuth.ts new file mode 100644 index 00000000..a0f19151 --- /dev/null +++ b/app/javascript/actions/OAuth/deleteOAuth.ts @@ -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> => ( + 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)); + } + } +); \ No newline at end of file diff --git a/app/javascript/actions/OAuth/requestOAuths.ts b/app/javascript/actions/OAuth/requestOAuths.ts new file mode 100644 index 00000000..15aef19e --- /dev/null +++ b/app/javascript/actions/OAuth/requestOAuths.ts @@ -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; +} + +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 +): OAuthsRequestActionTypes => ({ + type: OAUTHS_REQUEST_SUCCESS, + oAuths, +}); + +const oAuthsRequestFailure = (error: string): OAuthsRequestActionTypes => ({ + type: OAUTHS_REQUEST_FAILURE, + error, +}); + +export const requestOAuths = (): ThunkAction> => ( + async (dispatch) => { + dispatch(oAuthsRequestStart()); + + try { + const response = await fetch('/o_auths'); + const json = await response.json(); + + dispatch(oAuthsRequestSuccess(json)); + } catch (e) { + dispatch(oAuthsRequestFailure(e)); + } + } +) \ No newline at end of file diff --git a/app/javascript/actions/OAuth/submitOAuth.ts b/app/javascript/actions/OAuth/submitOAuth.ts new file mode 100644 index 00000000..9f061e85 --- /dev/null +++ b/app/javascript/actions/OAuth/submitOAuth.ts @@ -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> => 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); + } +}; \ No newline at end of file diff --git a/app/javascript/actions/OAuth/updateOAuth.ts b/app/javascript/actions/OAuth/updateOAuth.ts new file mode 100644 index 00000000..b9cbef15 --- /dev/null +++ b/app/javascript/actions/OAuth/updateOAuth.ts @@ -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> => 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); + } +}; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/AuthenticationFormPage.tsx b/app/javascript/components/SiteSettings/Authentication/AuthenticationFormPage.tsx new file mode 100644 index 00000000..6a1e63aa --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/AuthenticationFormPage.tsx @@ -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>; +} + +const AuthenticationFormPage = ({ + handleSubmitOAuth, + handleUpdateOAuth, + isSubmitting, + submitError, + selectedOAuth, + page, + setPage, +}: Props) => ( + <> + + + + { isSubmitting && } + { submitError && {submitError} } + + +); + +export default AuthenticationFormPage; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx b/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx new file mode 100644 index 00000000..bcb5d011 --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/AuthenticationIndexPage.tsx @@ -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>; + setSelectedOAuth: React.Dispatch>; +} + +const AuthenticationIndexPage = ({ + oAuths, + isSubmitting, + submitError, + + handleToggleEnabledOAuth, + handleDeleteOAuth, + + setPage, + setSelectedOAuth, +}: Props) => ( + <> + +

{ I18n.t('site_settings.authentication.title') }

+ + +
+ + + +); + +export default AuthenticationIndexPage; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/AuthenticationSiteSettingsP.tsx b/app/javascript/components/SiteSettings/Authentication/AuthenticationSiteSettingsP.tsx new file mode 100644 index 00000000..05051a06 --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/AuthenticationSiteSettingsP.tsx @@ -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; + onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise; + 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('index'); + const [selectedOAuth, setSelectedOAuth] = useState(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' ? + + : + oAuth.id === selectedOAuth)} + page={page} + setPage={setPage} + /> + ); +}; + +export default AuthenticationSiteSettingsP; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/OAuthForm.tsx b/app/javascript/components/SiteSettings/Authentication/OAuthForm.tsx new file mode 100644 index 00000000..1959e7b8 --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/OAuthForm.tsx @@ -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>; + + 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({ + 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 = 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 ( + <> + { + 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') } + +

{ I18n.t(`site_settings.authentication.form.title_${page}`) }

+
+
+
+ + + {errors.name && getValidationMessage(errors.name.type, 'o_auth', 'name')} +
+ +
+ + +
+
+ +
{ I18n.t('site_settings.authentication.form.subtitle_oauth_config') }
+
+
+ + + {errors.clientId && getValidationMessage(errors.clientId.type, 'o_auth', 'client_id')} +
+ +
+ + + { + page === 'edit' && + <> + + {I18n.t('site_settings.authentication.form.client_secret_help') + "\t"} + + + { + editClientSecret ? + setEditClientSecret(false)} className="link">{I18n.t('common.buttons.cancel')} + : + setEditClientSecret(true)} className="link">{I18n.t('common.buttons.edit')} + } +
+ + + } + {errors.clientSecret && getValidationMessage(errors.clientSecret.type, 'o_auth', 'client_secret')} +
+
+ +
+
+ + + {errors.authorizeUrl?.type === 'required' && getValidationMessage(errors.authorizeUrl.type, 'o_auth', 'authorize_url')} + {errors.authorizeUrl?.type === 'pattern' && I18n.t('common.validations.url')} +
+ +
+ + + {errors.tokenUrl?.type === 'required' && getValidationMessage(errors.tokenUrl.type, 'o_auth', 'token_url')} + {errors.tokenUrl?.type === 'pattern' && I18n.t('common.validations.url')} +
+
+ +
+ + + {errors.scope && getValidationMessage(errors.scope.type, 'o_auth', 'scope')} +
+ +
{ I18n.t('site_settings.authentication.form.subtitle_user_profile_config') }
+
+ + + {errors.profileUrl?.type === 'required' && getValidationMessage(errors.profileUrl.type, 'o_auth', 'profile_url')} + {errors.profileUrl?.type === 'pattern' && I18n.t('common.validations.url')} +
+ +
+
+ + + + {errors.jsonUserEmailPath && getValidationMessage(errors.jsonUserEmailPath.type, 'o_auth', 'json_user_email_path')} + +
+ +
+ + +
+
+ + +
+ + ); +} + +export default OAuthForm; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx b/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx new file mode 100644 index 00000000..6e82585d --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/OAuthProviderItem.tsx @@ -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>; + setSelectedOAuth: React.Dispatch>; +} + +const OAuthProviderItem = ({ + oAuth, + handleToggleEnabledOAuth, + handleDeleteOAuth, + setPage, + setSelectedOAuth, +}: Props) => ( +
  • +
    + + +
    + {oAuth.name} +
    + handleToggleEnabledOAuth(oAuth.id, !oAuth.isEnabled)} + checked={oAuth.isEnabled} + htmlId={`oAuth${oAuth.name}EnabledSwitch`} + /> +
    +
    +
    + + +
  • +); + +export default OAuthProviderItem; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx b/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx new file mode 100644 index 00000000..9a9e4916 --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/OAuthProvidersList.tsx @@ -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; + handleToggleEnabledOAuth(id: number, enabled: boolean): void; + handleDeleteOAuth(id: number): void; + setPage: React.Dispatch>; + setSelectedOAuth: React.Dispatch>; +} + +const OAuthProvidersList = ({ + oAuths, + handleToggleEnabledOAuth, + handleDeleteOAuth, + setPage, + setSelectedOAuth, +}: Props) => ( + <> +
    +

    { I18n.t('site_settings.authentication.oauth_subtitle') }

    + +
    + +
      + { + oAuths.map((oAuth, i) => ( + + )) + } +
    + +); + +export default OAuthProvidersList; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Authentication/index.tsx b/app/javascript/components/SiteSettings/Authentication/index.tsx new file mode 100644 index 00000000..9522e1e7 --- /dev/null +++ b/app/javascript/components/SiteSettings/Authentication/index.tsx @@ -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 { + store: Store; + + constructor(props: Props) { + super(props); + + this.store = createStoreHelper(); + } + + render() { + return ( + + + + ); + } +} + +export default AuthenticationSiteSettingsRoot; \ No newline at end of file diff --git a/app/javascript/components/SiteSettings/Boards/BoardForm.tsx b/app/javascript/components/SiteSettings/Boards/BoardForm.tsx index a5c1252b..f5c9bfd7 100644 --- a/app/javascript/components/SiteSettings/Boards/BoardForm.tsx +++ b/app/javascript/components/SiteSettings/Boards/BoardForm.tsx @@ -67,7 +67,7 @@ const BoardForm = ({ diff --git a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx index d599c2f2..a23a92a2 100644 --- a/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/General/GeneralSiteSettingsP.tsx @@ -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 = ({
    - + - {errors.siteName && I18n.t('site_settings.general.validations.site_name')} + {errors.siteName && getValidationMessage(errors.siteName.type, 'tenant', 'site_name')}
    - +
    - + diff --git a/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx b/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx index cd55d315..c0dd3489 100644 --- a/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx +++ b/app/javascript/components/SiteSettings/Users/UsersSiteSettingsP.tsx @@ -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 {
      { - users.items.map((user, i) => ( - ( + - )) + currentUserEmail={currentUserEmail} + currentUserRole={currentUserRole} + key={i} + /> + )) + : + }
    diff --git a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx index c3f88e2e..348722c7 100644 --- a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx @@ -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 = ({ - {errors.siteName && I18n.t('signup.step2.validations.site_name')} + {errors.siteName?.type === 'required' && getValidationMessage('required', 'tenant', 'site_name')}
    @@ -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 = ({
    - {errors.subdomain?.type === 'required' && I18n.t('signup.step2.validations.subdomain')} + {errors.subdomain?.type === 'required' && getValidationMessage('required', 'tenant', 'subdomain')} {errors.subdomain?.type === 'validate' && I18n.t('signup.step2.validations.subdomain_already_taken')} diff --git a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx index f273eba6..691170e4 100644 --- a/app/javascript/components/TenantSignUp/UserSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/UserSignUpForm.tsx @@ -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(); + const { + register, + handleSubmit, + setError, + getValues, + formState: { errors } + } = useForm(); const onSubmit: SubmitHandler = data => { if (data.password !== data.passwordConfirmation) { setError('passwordConfirmation', I18n.t('signup.step1.validations.password_mismatch')); @@ -53,22 +61,25 @@ const UserSignUpForm = ({ - { errors.fullName && I18n.t('signup.step1.validations.full_name') } + {errors.fullName && getValidationMessage('required', 'user', 'full_name')}
    - { errors.email && I18n.t('signup.step1.validations.email') } + {errors.email?.type === 'required' && getValidationMessage('required', 'user', 'email')} + + {errors.email?.type === 'pattern' && I18n.t('common.validations.email')} +
    @@ -76,22 +87,22 @@ const UserSignUpForm = ({ - { errors.password && I18n.t('signup.step1.validations.password', { n: 6 }) } + { errors.password && I18n.t('common.validations.password', { n: 6 }) }
    - { errors.passwordConfirmation && I18n.t('signup.step1.validations.password_mismatch') } + { errors.passwordConfirmation && I18n.t('common.validations.password_mismatch') }
    diff --git a/app/javascript/components/common/CopyToClipboardButton.tsx b/app/javascript/components/common/CopyToClipboardButton.tsx new file mode 100644 index 00000000..cfb98e2d --- /dev/null +++ b/app/javascript/components/common/CopyToClipboardButton.tsx @@ -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 ? + { + if (navigator.clipboard) { + navigator.clipboard.writeText(textToCopy).then(() => { + setReady(false); + setTimeout(() => setReady(true), 2000); + }, + alertError); + } else { + alertError(); + } + }} + > + {label} + + : + {copiedLabel} + ); +}; + +export default CopyToClipboardButton; \ No newline at end of file diff --git a/app/javascript/constants/regex.ts b/app/javascript/constants/regex.ts new file mode 100644 index 00000000..1f98bf77 --- /dev/null +++ b/app/javascript/constants/regex.ts @@ -0,0 +1,2 @@ +export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/; +export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/; \ No newline at end of file diff --git a/app/javascript/containers/AuthenticationSiteSettings.tsx b/app/javascript/containers/AuthenticationSiteSettings.tsx new file mode 100644 index 00000000..9380bcde --- /dev/null +++ b/app/javascript/containers/AuthenticationSiteSettings.tsx @@ -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 { + return dispatch(submitOAuth(oAuth, authenticityToken)); + }, + + onUpdateOAuth(id: number, form: ISiteSettingsOAuthForm, authenticityToken: string): Promise { + 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); \ No newline at end of file diff --git a/app/javascript/helpers/formUtils.ts b/app/javascript/helpers/formUtils.ts new file mode 100644 index 00000000..d70d794c --- /dev/null +++ b/app/javascript/helpers/formUtils.ts @@ -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) } + ) +); \ No newline at end of file diff --git a/app/javascript/interfaces/IOAuth.ts b/app/javascript/interfaces/IOAuth.ts new file mode 100644 index 00000000..fe7f283d --- /dev/null +++ b/app/javascript/interfaces/IOAuth.ts @@ -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, +}); \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/authenticationReducer.ts b/app/javascript/reducers/SiteSettings/authenticationReducer.ts new file mode 100644 index 00000000..f45d8c88 --- /dev/null +++ b/app/javascript/reducers/SiteSettings/authenticationReducer.ts @@ -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; \ No newline at end of file diff --git a/app/javascript/reducers/SiteSettings/boardsReducer.ts b/app/javascript/reducers/SiteSettings/boardsReducer.ts index e6450315..9cdf289c 100644 --- a/app/javascript/reducers/SiteSettings/boardsReducer.ts +++ b/app/javascript/reducers/SiteSettings/boardsReducer.ts @@ -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: diff --git a/app/javascript/reducers/SiteSettings/generalReducer.ts b/app/javascript/reducers/SiteSettings/generalReducer.ts index e2c876c3..2e9df84a 100644 --- a/app/javascript/reducers/SiteSettings/generalReducer.ts +++ b/app/javascript/reducers/SiteSettings/generalReducer.ts @@ -5,7 +5,6 @@ import { TENANT_UPDATE_FAILURE, } from '../../actions/Tenant/updateTenant'; - export interface SiteSettingsGeneralState { areUpdating: boolean; error: string; diff --git a/app/javascript/reducers/SiteSettings/postStatusesReducer.ts b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts index 1abb857f..f1c3bd97 100644 --- a/app/javascript/reducers/SiteSettings/postStatusesReducer.ts +++ b/app/javascript/reducers/SiteSettings/postStatusesReducer.ts @@ -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: diff --git a/app/javascript/reducers/SiteSettings/usersReducer.ts b/app/javascript/reducers/SiteSettings/usersReducer.ts index e48ece8e..aa81da7b 100644 --- a/app/javascript/reducers/SiteSettings/usersReducer.ts +++ b/app/javascript/reducers/SiteSettings/usersReducer.ts @@ -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, diff --git a/app/javascript/reducers/oAuthsReducer.ts b/app/javascript/reducers/oAuthsReducer.ts new file mode 100644 index 00000000..c22c7aa1 --- /dev/null +++ b/app/javascript/reducers/oAuthsReducer.ts @@ -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; + 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(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; \ No newline at end of file diff --git a/app/javascript/reducers/rootReducer.ts b/app/javascript/reducers/rootReducer.ts index 748d0f3d..e6072947 100644 --- a/app/javascript/reducers/rootReducer.ts +++ b/app/javascript/reducers/rootReducer.ts @@ -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 diff --git a/app/javascript/reducers/siteSettingsReducer.ts b/app/javascript/reducers/siteSettingsReducer.ts index bf9bae9a..7ca8bf58 100644 --- a/app/javascript/reducers/siteSettingsReducer.ts +++ b/app/javascript/reducers/siteSettingsReducer.ts @@ -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: diff --git a/app/javascript/stylesheets/common/_index.scss b/app/javascript/stylesheets/common/_index.scss index bb00ec50..a94855cb 100644 --- a/app/javascript/stylesheets/common/_index.scss +++ b/app/javascript/stylesheets/common/_index.scss @@ -182,4 +182,9 @@ .selectPicker { @extend .custom-select; +} + +.link { + cursor: pointer; + &:hover { text-decoration: underline; } } \ No newline at end of file diff --git a/app/javascript/stylesheets/components/SiteSettings/Authentication/index.scss b/app/javascript/stylesheets/components/SiteSettings/Authentication/index.scss new file mode 100644 index 00000000..d5da13d4 --- /dev/null +++ b/app/javascript/stylesheets/components/SiteSettings/Authentication/index.scss @@ -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; + } +} \ No newline at end of file diff --git a/app/javascript/stylesheets/main.scss b/app/javascript/stylesheets/main.scss index 6534b368..2292f4e4 100644 --- a/app/javascript/stylesheets/main.scss +++ b/app/javascript/stylesheets/main.scss @@ -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'; \ No newline at end of file diff --git a/app/models/o_auth.rb b/app/models/o_auth.rb new file mode 100644 index 00000000..7eb5b401 --- /dev/null +++ b/app/models/o_auth.rb @@ -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 diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 3bca9d13..66389269 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -1,5 +1,6 @@ class Tenant < ApplicationRecord has_many :boards + has_many :o_auths has_many :post_statuses has_many :posts has_many :users diff --git a/app/policies/o_auth_policy.rb b/app/policies/o_auth_policy.rb new file mode 100644 index 00000000..9a263320 --- /dev/null +++ b/app/policies/o_auth_policy.rb @@ -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 \ No newline at end of file diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 35671bfa..f6311f2a 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -40,6 +40,17 @@
    <%= f.submit t('common.forms.auth.sign_up'), class: "btn btn-dark btn-block" %>
    + +
    + + <% if not @o_auths.empty? %> + <% @o_auths.each do |o_auth| %> +

    + <%= link_to t('common.forms.auth.sign_up_with', { o_auth: o_auth.name }), + o_auth_start_path(o_auth, reason: 'user') %> +

    + <% end %> + <% end %> <% end %> <%= render "devise/shared/links" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 9e183d07..100d5956 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -29,6 +29,17 @@
    <%= f.submit t('common.forms.auth.log_in'), class: "btn btn-dark btn-block" %>
    + +
    + + <% if not @o_auths.empty? %> + <% @o_auths.each do |o_auth| %> +

    + <%= link_to t('common.forms.auth.log_in_with', { o_auth: o_auth.name }), + o_auth_start_path(o_auth, reason: 'user') %> +

    + <% end %> + <% end %> <% end %> <%= render "devise/shared/links" %> \ No newline at end of file diff --git a/app/views/o_auths/test.html.erb b/app/views/o_auths/test.html.erb new file mode 100644 index 00000000..6af168aa --- /dev/null +++ b/app/views/o_auths/test.html.erb @@ -0,0 +1,47 @@ + + + + <%= t('site_settings.authentication.test_page.title', { name: @o_auth.name }) %> + + +

    <%= t('site_settings.authentication.test_page.title', { name: @o_auth.name }) %>

    + +
    +

    <%= t('site_settings.authentication.test_page.fetched_user_data') %>

    + +
    <%= JSON.pretty_generate(@user_profile, { indent: "  ", object_nl: "\n" }) %>
    +
    + +
    +

    <%= t('activerecord.attributes.user.email') %> <%= @email_valid ? "✅" : "❌" %>

    +
      +
    • <%= t('site_settings.authentication.form.jsonUserEmailPath') %>: <%= @o_auth.json_user_email_path %>
    • +
    • <%= t('site_settings.authentication.test_page.found') %>: <%= @user_email %>
    • +
    +
    + +
    +

    <%= t('activerecord.attributes.user.full_name') %> <%= @name_valid ? "✅" : "⚠️" %>

    +
      +
    • <%= t('site_settings.authentication.form.jsonUserNamePath') %>: <%= @o_auth.json_user_name_path %>
    • +
    • <%= t('site_settings.authentication.test_page.found') %>: <%= @user_name %>
    • +
    +
    + +
    +

    <%= t('site_settings.authentication.test_page.summary') %> <%= !@email_valid ? "❌" : !@name_valid ? "⚠️" : "✅" %>

    + + <% if @email_valid and @name_valid %> +

    <%= t('site_settings.authentication.test_page.valid_configuration') %>

    + <% end %> + + <% if @email_valid and not @name_valid %> +

    <%= t('site_settings.authentication.test_page.warning_configuration', { name: t('defaults.user_full_name') }) %>

    + <% end %> + + <% if not @email_valid %> +

    <%= t('site_settings.authentication.test_page.invalid_configuration') %>

    + <% end %> +
    + + \ No newline at end of file diff --git a/app/views/site_settings/_menu.html.erb b/app/views/site_settings/_menu.html.erb index b6b3cc89..ba25ce4d 100644 --- a/app/views/site_settings/_menu.html.erb +++ b/app/views/site_settings/_menu.html.erb @@ -5,6 +5,7 @@
    <% 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 %> diff --git a/app/views/site_settings/authentication.html.erb b/app/views/site_settings/authentication.html.erb new file mode 100644 index 00000000..15a15e10 --- /dev/null +++ b/app/views/site_settings/authentication.html.erb @@ -0,0 +1,13 @@ +
    + <%= render 'menu' %> +
    + <%= + react_component( + 'SiteSettings/Authentication', + { + authenticityToken: form_authenticity_token + } + ) + %> +
    +
    \ No newline at end of file diff --git a/app/workflows/OAuthExchangeAuthCodeForProfile.rb b/app/workflows/OAuthExchangeAuthCodeForProfile.rb new file mode 100644 index 00000000..12ae8107 --- /dev/null +++ b/app/workflows/OAuthExchangeAuthCodeForProfile.rb @@ -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 \ No newline at end of file diff --git a/app/workflows/OAuthSignInUser.rb b/app/workflows/OAuthSignInUser.rb new file mode 100644 index 00000000..da35c397 --- /dev/null +++ b/app/workflows/OAuthSignInUser.rb @@ -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 \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 0fb2fd83..2aaeb723 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/environments/development.rb b/config/environments/development.rb index d043314e..310e869e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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 diff --git a/config/locales/backend/backend.en.yml b/config/locales/backend/backend.en.yml index 24ea5371..f1157c19 100644 --- a/config/locales/backend/backend.en.yml +++ b/config/locales/backend/backend.en.yml @@ -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)' \ No newline at end of file + too_long: 'is too long (maximum %{count} characters)' \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index b5315d1a..440362bb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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' \ No newline at end of file + 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' \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index d88be748..37434ef2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20220727090932_create_o_auths.rb b/db/migrate/20220727090932_create_o_auths.rb new file mode 100644 index 00000000..35eb3a63 --- /dev/null +++ b/db/migrate/20220727090932_create_o_auths.rb @@ -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 diff --git a/db/migrate/20220727094200_add_not_null_to_o_auth_is_enabled.rb b/db/migrate/20220727094200_add_not_null_to_o_auth_is_enabled.rb new file mode 100644 index 00000000..8010a2e6 --- /dev/null +++ b/db/migrate/20220727094200_add_not_null_to_o_auth_is_enabled.rb @@ -0,0 +1,5 @@ +class AddNotNullToOAuthIsEnabled < ActiveRecord::Migration[6.0] + def change + change_column_null :o_auths, :is_enabled, false + end +end diff --git a/db/schema.rb b/db/schema.rb index f5ede151..a8b5c59e 100644 --- a/db/schema.rb +++ b/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" diff --git a/docker-compose.yml b/docker-compose.yml index 2b7cd1ce..5a33fbb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: dockerfile: ./docker/app/Dockerfile environment: - UPDATE=0 + - BASE_URL - ENVIRONMENT - SECRET_KEY_BASE - POSTGRES_USER diff --git a/spec/factories/o_auths.rb b/spec/factories/o_auths.rb new file mode 100644 index 00000000..3c062ddf --- /dev/null +++ b/spec/factories/o_auths.rb @@ -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 diff --git a/spec/helpers/o_auths_helper_spec.rb b/spec/helpers/o_auths_helper_spec.rb new file mode 100644 index 00000000..44368d0c --- /dev/null +++ b/spec/helpers/o_auths_helper_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/models/o_auth_spec.rb b/spec/models/o_auth_spec.rb new file mode 100644 index 00000000..a23c086e --- /dev/null +++ b/spec/models/o_auth_spec.rb @@ -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