1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-19 08:52:25 +01:00

Merge branch 'openid' into v5.4

This commit is contained in:
Sylvain 2022-04-25 15:43:03 +02:00
commit 0dd6f4bff5
96 changed files with 2819 additions and 1334 deletions

View File

@ -15,7 +15,9 @@
"moment": true,
"_": true,
"Humanize": true,
"GTM": true
"GTM": true,
"$": true,
"KeyboardEvent": true
},
"plugins": ["html-erb"],
"overrides": [

View File

@ -1,12 +1,15 @@
# Changelog Fab-manager
- No longer needed to recompile the assets when switching the authentication provider
- Updated the documentation about the minimum docker version
- Updated nodejs version to 16.13.2 for dev environment, to reflect production version
- Changed the apparence of the modal dialogs (React): no more logo and the close button appears in full-text in the top right corner.
- Use react-hook-form to manage and validate forms
- New text editor
- Change font family to "Work Sans"
- Updated Node to 16.13.2
- Updated eslint to v8 and eslint related packages to their latest versions
- Updated typescript to v4.6.3
- Updated react-select to v5.2.2
- Webpack overlay will now report eslint issues
- Linted all code according to eslint rules
- Fix a bug: Refused to connect to 'wss://localhost:3035/ws' when using a https tunnel in development mode

View File

@ -69,6 +69,7 @@ gem 'devise', '>= 4.6.0'
gem 'omniauth', '~> 1.9.0'
gem 'omniauth-oauth2'
gem 'omniauth_openid_connect'
gem 'omniauth-rails_csrf_protection', '~> 0.1'
gem 'rolify'

View File

@ -50,6 +50,7 @@ GEM
tzinfo (~> 1.1)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
afm (0.2.2)
ansi (1.5.0)
api-pagination (4.8.2)
@ -57,12 +58,14 @@ GEM
rails (>= 4.1)
arel (9.0.0)
ast (2.4.0)
attr_required (1.0.1)
awesome_print (1.8.0)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
bcrypt (3.1.13)
bindata (2.4.10)
bindex (0.8.1)
bootsnap (1.4.6)
msgpack (~> 1.0)
@ -164,6 +167,7 @@ GEM
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
icalendar (2.5.3)
@ -179,6 +183,10 @@ GEM
jbuilder_cache_multi (0.1.0)
jbuilder (>= 1.5.0, < 3)
json (2.3.1)
json-jwt (1.13.0)
activesupport (>= 4.2)
aes_key_wrap
bindata
jsonpath (1.1.0)
multi_json
jwt (2.2.1)
@ -249,6 +257,20 @@ GEM
omniauth-rails_csrf_protection (0.1.2)
actionpack (>= 4.2)
omniauth (>= 1.3.1)
omniauth_openid_connect (0.4.0)
addressable (~> 2.5)
omniauth (>= 1.9, < 3)
openid_connect (~> 1.1)
openid_connect (1.3.0)
activemodel
attr_required (>= 1.0.0)
json-jwt (>= 1.5.0)
rack-oauth2 (>= 1.6.1)
swd (>= 1.0.0)
tzinfo
validate_email
validate_url
webfinger (>= 1.0.1)
openlab_ruby (0.0.7)
httparty (~> 0.20)
orm_adapter (0.5.0)
@ -280,6 +302,12 @@ GEM
raabro (1.4.0)
racc (1.6.0)
rack (2.2.3)
rack-oauth2 (1.19.0)
activesupport
attr_required
httpclient
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-proxy (0.7.2)
rack
rack-test (1.1.0)
@ -396,6 +424,10 @@ GEM
sprockets (>= 3.0.0)
ssrf_filter (1.0.7)
stripe (5.29.0)
swd (1.3.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
sync (0.5.0)
sys-filesystem (1.3.3)
ffi
@ -419,6 +451,12 @@ GEM
tzinfo (>= 1.0.0)
unicode-display_width (1.4.1)
uniform_notifier (1.14.2)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.13)
activemodel (>= 3.0.0)
public_suffix
vcr (6.0.0)
virtus (1.0.5)
axiom-types (~> 0.1)
@ -432,6 +470,9 @@ GEM
activemodel (>= 5.0)
bindex (>= 0.4.0)
railties (>= 5.0)
webfinger (1.2.0)
activesupport
httpclient (>= 2.4)
webmock (3.8.2)
addressable (>= 2.3.6)
crack (>= 0.3.2)
@ -483,6 +524,7 @@ DEPENDENCIES
omniauth (~> 1.9.0)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 0.1)
omniauth_openid_connect
openlab_ruby
pdf-reader
pg

View File

@ -28,6 +28,12 @@ class API::AuthProvidersController < API::ApiController
end
end
def strategy_name
authorize AuthProvider
@provider = AuthProvider.new(providable_type: params[:providable_type], name: params[:name])
render json: @provider.strategy_name
end
def show
authorize AuthProvider
end
@ -78,16 +84,24 @@ class API::AuthProvidersController < API::ApiController
def provider_params
if params['auth_provider']['providable_type'] == DatabaseProvider.name
params.require(:auth_provider).permit(:name, :providable_type)
params.require(:auth_provider).permit(:name, :providable_type, providable_attributes: [:id])
elsif params['auth_provider']['providable_type'] == OAuth2Provider.name
params.require(:auth_provider)
.permit(:name, :providable_type,
providable_attributes: [:id, :base_url, :token_endpoint, :authorization_endpoint, :logout_endpoint,
:profile_url, :client_id, :client_secret, :scopes,
o_auth2_mappings_attributes: [:id, :local_model, :local_field, :api_field,
:api_endpoint, :api_data_type, :_destroy,
transformation: [:type, :format, :true_value,
:false_value, mapping: %i[from to]]]])
providable_attributes: %i[id base_url token_endpoint authorization_endpoint
profile_url client_id client_secret scopes],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
:_destroy, transformation: [:type, :format, :true_value, :false_value,
mapping: %i[from to]]])
elsif params['auth_provider']['providable_type'] == OpenIdConnectProvider.name
params.require(:auth_provider)
.permit(:name, :providable_type,
providable_attributes: %i[id issuer discovery client_auth_method scope prompt send_scope_to_token_endpoint
client__identifier client__secretclient__authorization_endpoint client__token_endpoint
client__userinfo_endpoint client__jwks_uri client__end_session_endpoint profile_url],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
:_destroy, transformation: [:type, :format, :true_value, :false_value,
mapping: %i[from to]]])
end
end
end

View File

@ -73,7 +73,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
logger.error 'user already linked'
redirect_to root_url, alert: t('omniauth.this_account_is_already_linked_to_an_user_of_the_platform', NAME: active_provider.name)
rescue StandardError => e
logger.unknown "an expected error occurred: #{e}"
logger.error "an expected error occurred: #{e}"
raise e
end
end

View File

@ -85,7 +85,8 @@ require('src/javascript/plugins.js.erb');
function importAll (r) { r.keys().forEach(r); }
importAll(require.context('src/javascript/components/', true, /.*/));
// we do not include markdown files (*.md)
importAll(require.context('src/javascript/components/', true, /^.+\.(?!md).+/));
importAll(require.context('src/javascript/controllers/', true, /.*/));
importAll(require.context('src/javascript/services/', true, /.*/));
importAll(require.context('src/javascript/directives/', true, /.*/));

View File

@ -0,0 +1,39 @@
import { AuthenticationProvider, MappingFields } from '../models/authentication-provider';
import { AxiosResponse } from 'axios';
import apiClient from './clients/api-client';
export default class AuthProviderAPI {
static async index (): Promise<Array<AuthenticationProvider>> {
const res: AxiosResponse<Array<AuthenticationProvider>> = await apiClient.get('/api/auth_providers');
return res?.data;
}
static async get (id: number): Promise<AuthenticationProvider> {
const res: AxiosResponse<AuthenticationProvider> = await apiClient.get(`/api/auth_providers/${id}`);
return res?.data;
}
static async create (authProvider: AuthenticationProvider): Promise<AuthenticationProvider> {
const res: AxiosResponse<AuthenticationProvider> = await apiClient.post('/api/auth_providers', authProvider);
return res?.data;
}
static async update (authProvider: AuthenticationProvider): Promise<AuthenticationProvider> {
const res: AxiosResponse<AuthenticationProvider> = await apiClient.put(`/api/auth_providers/${authProvider.id}`, authProvider);
return res?.data;
}
static async delete (id: number): Promise<void> {
await apiClient.delete(`/api/auth_providers/${id}`);
}
static async mappingFields (): Promise<MappingFields> {
const res: AxiosResponse<MappingFields> = await apiClient.get('/api/auth_providers/mapping_fields');
return res?.data;
}
static async strategyName (authProvider: AuthenticationProvider): Promise<string> {
const res: AxiosResponse<string> = await apiClient.get(`/api/auth_providers/strategy_name?providable_type=${authProvider.providable_type}&name=${authProvider.name}`);
return res?.data;
}
}

View File

@ -0,0 +1,9 @@
import axios, { AxiosInstance } from 'axios';
function client (host: string): AxiosInstance {
return axios.create({
baseURL: host
});
}
export default client;

View File

@ -0,0 +1,13 @@
import ssoClient from '../clients/sso-client';
import { AxiosResponse } from 'axios';
import { OpenIdConfiguration } from '../../models/sso';
export default class SsoClient {
/**
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
*/
static async openIdConfiguration (host: string): Promise<OpenIdConfiguration> {
const res: AxiosResponse<OpenIdConfiguration> = await ssoClient(host).get('.well-known/openid-configuration');
return res?.data;
}
}

View File

@ -0,0 +1,17 @@
# components
This directory is holding the components built with [React](https://reactjs.org/).
During the migration phase, these components may be included in [the legacy angularJS app](../../templates) using [react2angular](https://github.com/coatue-oss/react2angular).
These components must be written using the following conventions:
- The component name must be in CamelCase.
- The component must be exported as a named export (no `export default`).
- A component `FooBar` must have a `className="foo-bar"` attribute on its top-level element.
- The stylesheet associated with the component must be located in `app/frontend/src/stylesheets/modules/same-directory-structure/foo-bar.scss`.
- All methods in the component must be commented with a comment block.
- Other constants and variables must be commented with an inline block.
- Depending on if we want to use the `<Suspense>` wrapper or not, we can export the component directly or wrap it in a `<Loader>` wrapper.
- When a component is used in angularJS, the wrapper is required. The component must be named like `const Foo` (no export if not used in React) and must have a `const FooWrapper` at the end of its file, which wraps the component in a `<Loader>`.
- Translations must be grouped per component. For example, the `FooBar` component must have its translations in the `config/locales/app.$SCOPE.en.yml` file, under the `foo_bar` key.

View File

@ -0,0 +1,31 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
export interface BooleanMappingFormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
fieldMappingId: number,
}
/**
* Partial form to map an internal boolean field to an external API providing a string value.
*/
export const BooleanMappingForm = <TFieldValues extends FieldValues>({ register, fieldMappingId }: BooleanMappingFormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
return (
<div className="boolean-mapping-form">
<h4>{t('app.admin.authentication.boolean_mapping_form.mappings')}</h4>
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.true_value`}
register={register}
rules={{ required: true }}
label={t('app.admin.authentication.boolean_mapping_form.true_value')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.false_value`}
register={register}
rules={{ required: true }}
label={t('app.admin.authentication.boolean_mapping_form.false_value')} />
</div>
);
};

View File

@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import AuthProviderAPI from '../../api/auth-provider';
import { AuthenticationProviderMapping, MappingFields, mappingType, ProvidableType } from '../../models/authentication-provider';
import { Control, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FormSelect } from '../form/form-select';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { TypeMappingModal } from './type-mapping-modal';
import { useImmer } from 'use-immer';
import { Oauth2DataMappingForm } from './oauth2-data-mapping-form';
import { OpenidConnectDataMappingForm } from './openid-connect-data-mapping-form';
export interface DataMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
providerType: ProvidableType,
setValue: UseFormSetValue<TFieldValues>,
currentFormValues: Array<AuthenticationProviderMapping>,
}
type selectModelFieldOption = { value: string, label: string };
/**
* Partial form to define the mapping of the data between the API of the authentication provider and the application internals.
*/
export const DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, providerType, setValue, currentFormValues }: DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const [dataMapping, setDataMapping] = useState<MappingFields>(null);
const [isOpenTypeMappingModal, updateIsOpenTypeMappingModal] = useImmer<Map<number, boolean>>(new Map());
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes' as ArrayPath<TFieldValues> });
const output = useWatch({ name: 'auth_provider_mappings_attributes' as Path<TFieldValues>, control });
/**
* Build the list of available models for the data mapping
*/
const buildModelOptions = (): Array<selectModelFieldOption> => {
if (!dataMapping) return [];
return Object.keys(dataMapping).map(model => {
return {
label: model,
value: model
};
}) || [];
};
/**
* Build the list of fields of the current model for the data mapping
*/
const buildFieldOptions = (formData: Array<TFieldValues>, index: number): Array<selectModelFieldOption> => {
if (!dataMapping) return [];
return dataMapping[getModel(formData, index)]?.map(field => {
return {
label: field[0],
value: field[0]
};
}) || [];
};
/**
* Return the name of the modal for the given index, in the current data-mapping form
*/
const getModel = (formData: Array<TFieldValues>, index: number): string => {
return formData ? formData[index]?.local_model : undefined;
};
/**
* Return the name of the field for the given index, in the current data-mapping form
*/
const getField = (formData: Array<TFieldValues>, index: number): string => {
return formData ? formData[index]?.local_field : undefined;
};
/**
* Return the type of data expected for the given index, in the current data-mapping form
*/
const getDataType = (formData: Array<TFieldValues>, index: number): mappingType => {
const model = getModel(formData, index);
const field = getField(formData, index);
if (model && field && dataMapping) {
return dataMapping[model]?.find(f => f[0] === field)?.[1];
}
};
/**
* Open/closes the "edit type mapping" modal dialog for the given mapping index
*/
const toggleTypeMappingModal = (index: number): () => void => {
return () => {
updateIsOpenTypeMappingModal(draft => draft.set(index, !draft.get(index)));
};
};
// fetch the mapping data from the API on mount
useEffect(() => {
AuthProviderAPI.mappingFields().then((data) => {
setDataMapping(data);
});
}, []);
return (
<div className="data-mapping-form array-mapping-form">
<h4>{t('app.admin.authentication.data_mapping_form.define_the_fields_mapping')}</h4>
<div className="mapping-actions">
<FabButton
icon={<i className="fa fa-plus"/>}
onClick={() => append({})}>
{t('app.admin.authentication.data_mapping_form.add_a_match')}
</FabButton>
</div>
{fields.map((item, index) => (
<div key={item.id} className="mapping-item">
<div className="inputs">
<FormInput id={`auth_provider_mappings_attributes.${index}.id`} register={register} type="hidden" />
<div className="local-data">
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_model`}
control={control} rules={{ required: true }}
options={buildModelOptions()}
label={t('app.admin.authentication.data_mapping_form.model')}/>
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_field`}
options={buildFieldOptions(output, index)}
control={control}
rules={{ required: true }}
label={t('app.admin.authentication.data_mapping_form.field')} />
</div>
<div className="remote-data">
{providerType === 'OAuth2Provider' && <Oauth2DataMappingForm register={register} control={control} index={index} />}
{providerType === 'OpenIdConnectProvider' && <OpenidConnectDataMappingForm register={register}
index={index}
setValue={setValue}
currentFormValues={currentFormValues} />}
</div>
</div>
<div className="actions">
<FabButton icon={<i className="fa fa-random" />}
onClick={toggleTypeMappingModal(index)}
disabled={getField(output, index) === undefined}
tooltip={t('app.admin.authentication.data_mapping_form.data_mapping')} />
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
<TypeMappingModal model={getModel(output, index)}
field={getField(output, index)}
type={getDataType(output, index)}
isOpen={isOpenTypeMappingModal.get(index)}
toggleModal={toggleTypeMappingModal(index)}
control={control} register={register}
fieldMappingId={index} />
</div>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { FormInput } from '../form/form-input';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
interface DatabaseFormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
}
/**
* Partial form to fill the settings for a new/existing database provider.
*/
export const DatabaseForm = <TFieldValues extends FieldValues>({ register }: DatabaseFormProps<TFieldValues>) => {
return (
<div className="database-form">
<FormInput id="providable_attributes.id"
register={register}
type="hidden" />
</div>
);
};

View File

@ -0,0 +1,52 @@
import React from 'react';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormSelect } from '../form/form-select';
import { Control } from 'react-hook-form/dist/types/form';
export interface DateMappingFormProps<TFieldValues, TContext extends object> {
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
}
/**
* Partial form for mapping an internal date field to an external API.
*/
export const DateMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ control, fieldMappingId }: DateMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
// available date formats
const dateFormats = [
{
label: 'ISO 8601',
value: 'iso8601'
},
{
label: 'RFC 2822',
value: 'rfc2822'
},
{
label: 'RFC 3339',
value: 'rfc3339'
},
{
label: 'Timestamp (s)',
value: 'timestamp-s'
},
{
label: 'Timestamp (ms)',
value: 'timestamp-ms'
}
];
return (
<div className="date-mapping-form">
<h4>{t('app.admin.authentication.date_mapping_form.input_format')}</h4>
<FormSelect id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.format`}
control={control}
rules={{ required: true }}
options={dateFormats}
label={t('app.admin.authentication.date_mapping_form.date_format')} />
</div>
);
};

View File

@ -0,0 +1,51 @@
import React from 'react';
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FormInput } from '../form/form-input';
export interface IntegerMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
}
/**
* Partial for to map an internal integer field to an external API providing a string value.
*/
export const IntegerMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: IntegerMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
return (
<div className="integer-mapping-form array-mapping-form">
<h4>{t('app.admin.authentication.integer_mapping_form.mappings')}</h4>
<div className="mapping-actions">
<FabButton
icon={<i className="fa fa-plus" />}
onClick={() => append({})} />
</div>
{fields.map((item, index) => (
<div key={item.id} className="mapping-item">
<div className="inputs">
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
register={register}
rules={{ required: true }}
label={t('app.admin.authentication.integer_mapping_form.mapping_from')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
register={register}
type="number"
rules={{ required: true }}
label={t('app.admin.authentication.integer_mapping_form.mapping_to')} />
</div>
<div className="actions">
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
</div>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,38 @@
import React from 'react';
import { UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { FormSelect } from '../form/form-select';
import { HtmlTranslate } from '../base/html-translate';
import { useTranslation } from 'react-i18next';
interface Oauth2DataMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
index: number,
}
export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, index }: Oauth2DataMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
return (
<div className="oauth2-data-mapping-form">
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
register={register}
rules={{ required: true }}
placeholder="/api/resource..."
label={t('app.admin.authentication.oauth2_data_mapping_form.api_endpoint_url')} />
<FormSelect id={`auth_provider_mappings_attributes.${index}.api_data_type`}
options={[{ label: 'JSON', value: 'json' }]}
control={control} rules={{ required: true }}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_type')} />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
placeholder="field_name..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.oauth2_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.oauth2_data_mapping_form.api_field')} />
</div>
);
};

View File

@ -0,0 +1,68 @@
import React from 'react';
import { FormInput } from '../form/form-input';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabOutputCopy } from '../base/fab-output-copy';
interface Oauth2FormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
strategyName?: string,
}
/**
* Partial form to fill the OAuth2 settings for a new/existing authentication provider.
*/
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName }: Oauth2FormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
// regular expression to validate the the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
/**
* Build the callback URL, based on the strategy name.
*/
const buildCallbackUrl = (): string => {
return `${window.location.origin}/users/auth/${strategyName}/callback`;
};
return (
<div className="oauth2-form">
<hr/>
<FabOutputCopy text={buildCallbackUrl()} label={t('app.admin.authentication.oauth2_form.authorization_callback_url')} />
<FormInput id="providable_attributes.base_url"
register={register}
placeholder="https://sso.example.net..."
label={t('app.admin.authentication.oauth2_form.common_url')}
rules={{ required: true, pattern: urlRegex }} />
<FormInput id="providable_attributes.authorization_endpoint"
register={register}
placeholder="/oauth2/auth..."
label={t('app.admin.authentication.oauth2_form.authorization_endpoint')}
rules={{ required: true, pattern: endpointRegex }} />
<FormInput id="providable_attributes.token_endpoint"
register={register}
placeholder="/oauth2/token..."
label={t('app.admin.authentication.oauth2_form.token_acquisition_endpoint')}
rules={{ required: true, pattern: endpointRegex }} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://exemple.net/user..."
label={t('app.admin.authentication.oauth2_form.profile_edition_url')}
tooltip={t('app.admin.authentication.oauth2_form.profile_edition_url_help')}
rules={{ required: true, pattern: urlRegex }} />
<FormInput id="providable_attributes.client_id"
register={register}
label={t('app.admin.authentication.oauth2_form.client_identifier')}
rules={{ required: true }} />
<FormInput id="providable_attributes.client_secret"
register={register}
label={t('app.admin.authentication.oauth2_form.client_secret')}
rules={{ required: true }} />
<FormInput id="providable_attributes.scopes" register={register}
placeholder="profile,email..."
label={t('app.admin.authentication.oauth2_form.scopes')} />
</div>
);
};

View File

@ -0,0 +1,82 @@
import React from 'react';
import { Path, UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FormInput } from '../form/form-input';
import { HtmlTranslate } from '../base/html-translate';
import { useTranslation } from 'react-i18next';
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { FabButton } from '../base/fab-button';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { AuthenticationProviderMapping } from '../../models/authentication-provider';
interface OpenidConnectDataMappingFormProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>,
currentFormValues: Array<AuthenticationProviderMapping>,
index: number,
}
export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({ register, setValue, currentFormValues, index }: OpenidConnectDataMappingFormProps<TFieldValues>) => {
const { t } = useTranslation('admin');
const standardConfiguration = {
'user.uid': { api_field: 'sub' },
'user.email': { api_field: 'email' },
'user.username': { api_field: 'preferred_username' },
'profile.first_name': { api_field: 'given_name' },
'profile.last_name': { api_field: 'family_name' },
'profile.avatar': { api_field: 'picture' },
'profile.website': { api_field: 'website' },
'profile.gender': { api_field: 'gender', transformation: { true_value: 'male', false_value: 'female' } },
'profile.birthday': { api_field: 'birthdate', transformation: { format: 'iso8601' } },
'profile.phone': { api_field: 'phone_number' },
'profile.address': { api_field: 'address.formatted' }
};
/**
* Set the data mapping according to the standard OpenID Connect specification
*/
const openIdStandardConfiguration = (): void => {
const model = currentFormValues[index]?.local_model;
const field = currentFormValues[index]?.local_field;
const configuration = standardConfiguration[`${model}.${field}`];
setValue(
`auth_provider_mappings_attributes.${index}.api_field` as Path<TFieldValues>,
configuration.api_field as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
if (configuration.transformation) {
Object.keys(configuration.transformation).forEach((key) => {
setValue(
`auth_provider_mappings_attributes.${index}.transformation.${key}` as Path<TFieldValues>,
configuration.transformation[key] as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
});
}
};
return (
<div className="openid-connect-data-mapping-form">
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
type="hidden"
register={register}
rules={{ required: true }}
defaultValue="user_info" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_data_type`}
type="hidden"
register={register}
rules={{ required: true }}
defaultValue="json" />
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
register={register}
rules={{ required: true }}
placeholder="claim..."
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_data_mapping_form.api_field_help_html" />}
label={t('app.admin.authentication.openid_connect_data_mapping_form.api_field')} />
<FabButton
icon={<i className="fa fa-magic" />}
className="auto-configure-button"
onClick={openIdStandardConfiguration}
tooltip={t('app.admin.authentication.openid_connect_data_mapping_form.openid_standard_configuration')} />
</div>
);
};

View File

@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { Path, UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FormInput } from '../form/form-input';
import { FormSelect } from '../form/form-select';
import { Control, FormState, UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { HtmlTranslate } from '../base/html-translate';
import { OpenIdConnectProvider } from '../../models/authentication-provider';
import SsoClient from '../../api/external/sso';
import { FieldPathValue } from 'react-hook-form/dist/types/path';
import { FormMultiSelect } from '../form/form-multi-select';
interface OpenidConnectFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
currentFormValues: OpenIdConnectProvider,
formState: FormState<TFieldValues>,
setValue: UseFormSetValue<TFieldValues>,
}
export const OpenidConnectForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, currentFormValues, formState, setValue }: OpenidConnectFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
// saves the state of the discovery endpoint
const [discoveryAvailable, setDiscoveryAvailable] = useState<boolean>(false);
const [scopesAvailable, setScopesAvailable] = useState<string[]>(null);
// when we have detected a discovery endpoint, we mark it as available
useEffect(() => {
setValue(
'providable_attributes.discovery' as Path<TFieldValues>,
discoveryAvailable as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
);
}, [discoveryAvailable]);
// when the component is mounted, we try to discover the discovery endpoint for the current configuration (if any)
useEffect(() => {
checkForDiscoveryEndpoint({ target: { value: currentFormValues?.issuer } } as React.ChangeEvent<HTMLInputElement>);
}, []);
// regular expression to validate the the input fields
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
/**
* If the discovery endpoint is available, the user will be able to choose to use it or not.
* Otherwise, he will need to end the client configuration manually.
*/
const buildDiscoveryOptions = () => {
if (discoveryAvailable) {
return [
{ value: true, label: t('app.admin.authentication.openid_connect_form.discovery_enabled') },
{ value: false, label: t('app.admin.authentication.openid_connect_form.discovery_disabled') }
];
}
return [
{ value: false, label: t('app.admin.authentication.openid_connect_form.discovery_disabled') }
];
};
/**
* Callback that check for the existence of the .well-known/openid-configuration endpoint, for the given issuer.
* This callback is triggered when the user changes the issuer field.
*/
const checkForDiscoveryEndpoint = (e: React.ChangeEvent<HTMLInputElement>) => {
SsoClient.openIdConfiguration(e.target.value).then((configuration) => {
setDiscoveryAvailable(true);
setScopesAvailable(configuration.scopes_supported);
}).catch(() => {
setDiscoveryAvailable(false);
setScopesAvailable(null);
});
};
return (
<div className="openid-connect-form">
<hr/>
<FormInput id="providable_attributes.issuer"
register={register}
label={t('app.admin.authentication.openid_connect_form.issuer')}
placeholder="https://sso.exemple.com"
tooltip={t('app.admin.authentication.openid_connect_form.issuer_help')}
rules={{ required: true, pattern: urlRegex }}
onChange={checkForDiscoveryEndpoint}
debounce={400}
warning={!discoveryAvailable && { 'providable_attributes.issuer': { message: t('app.admin.authentication.openid_connect_form.discovery_unavailable') } }}
formState={formState} />
<FormSelect id="providable_attributes.discovery"
label={t('app.admin.authentication.openid_connect_form.discovery')}
tooltip={t('app.admin.authentication.openid_connect_form.discovery_help')}
options={buildDiscoveryOptions()}
valueDefault={discoveryAvailable}
control={control} />
<FormSelect id="providable_attributes.client_auth_method"
label={t('app.admin.authentication.openid_connect_form.client_auth_method')}
tooltip={t('app.admin.authentication.openid_connect_form.client_auth_method_help')}
options={[
{ value: 'basic', label: t('app.admin.authentication.openid_connect_form.client_auth_method_basic') },
{ value: 'jwks', label: t('app.admin.authentication.openid_connect_form.client_auth_method_jwks') }
]}
valueDefault={'basic'}
control={control} />
{!scopesAvailable && <FormInput id="providable_attributes.scope"
register={register}
label={t('app.admin.authentication.openid_connect_form.scope')}
placeholder="openid,profile,email"
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.scope_help_html" />} />}
{scopesAvailable && <FormMultiSelect id="providable_attributes.scope"
label={t('app.admin.authentication.openid_connect_form.scope')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.scope_help_html" />}
options={scopesAvailable.map((scope) => ({ value: scope, label: scope }))}
control={control} />}
<FormSelect id="providable_attributes.prompt"
label={t('app.admin.authentication.openid_connect_form.prompt')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.prompt_help_html" />}
options={[
{ value: 'none', label: t('app.admin.authentication.openid_connect_form.prompt_none') },
{ value: 'login', label: t('app.admin.authentication.openid_connect_form.prompt_login') },
{ value: 'consent', label: t('app.admin.authentication.openid_connect_form.prompt_consent') },
{ value: 'select_account', label: t('app.admin.authentication.openid_connect_form.prompt_select_account') }
]}
clearable
control={control} />
<FormSelect id="providable_attributes.send_scope_to_token_endpoint"
label={t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_help')}
options={[
{ value: false, label: t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_false') },
{ value: true, label: t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_true') }
]}
valueDefault={true}
control={control} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://sso.exemple.com/my-account"
label={t('app.admin.authentication.openid_connect_form.profile_edition_url')}
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ pattern: urlRegex }} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
label={t('app.admin.authentication.openid_connect_form.client__identifier')}
rules={{ required: true }}
register={register} />
<FormInput id="providable_attributes.client__secret"
label={t('app.admin.authentication.openid_connect_form.client__secret')}
rules={{ required: true }}
register={register} />
{!currentFormValues?.discovery && <div className="client-options-without-discovery">
<FormInput id="providable_attributes.client__authorization_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__authorization_endpoint')}
placeholder="/authorize"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
<FormInput id="providable_attributes.client__token_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__token_endpoint')}
placeholder="/token"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
<FormInput id="providable_attributes.client__userinfo_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__userinfo_endpoint')}
placeholder="/userinfo"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
{currentFormValues?.client_auth_method === 'jwks' && <FormInput id="providable_attributes.client__jwks_uri"
label={t('app.admin.authentication.openid_connect_form.client__jwks_uri')}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: endpointRegex }}
placeholder="/jwk"
register={register} />}
<FormInput id="providable_attributes.client__end_session_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint_help')}
rules={{ pattern: endpointRegex }}
register={register} />
</div>}
</div>
);
};

View File

@ -0,0 +1,136 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useForm, SubmitHandler, useWatch } from 'react-hook-form';
import { react2angular } from 'react2angular';
import { debounce as _debounce } from 'lodash';
import {
AuthenticationProvider,
AuthenticationProviderMapping,
OpenIdConnectProvider,
ProvidableType
} from '../../models/authentication-provider';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { FormInput } from '../form/form-input';
import { useTranslation } from 'react-i18next';
import { FormSelect } from '../form/form-select';
import { Oauth2Form } from './oauth2-form';
import { DataMappingForm } from './data-mapping-form';
import { FabButton } from '../base/fab-button';
import AuthProviderAPI from '../../api/auth-provider';
import { OpenidConnectForm } from './openid-connect-form';
import { DatabaseForm } from './database-form';
declare const Application: IApplication;
// list of supported authentication methods
const METHODS = {
DatabaseProvider: 'local_database',
OAuth2Provider: 'oauth2',
OpenIdConnectProvider: 'openid_connect'
};
interface ProviderFormProps {
action: 'create' | 'update',
provider?: AuthenticationProvider,
onError: (message: string) => void,
onSuccess: (message: string) => void,
}
type selectProvidableTypeOption = { value: string, label: string };
/**
* Form to create or update an authentication provider.
*/
export const ProviderForm: React.FC<ProviderFormProps> = ({ action, provider, onError, onSuccess }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<AuthenticationProvider>({ defaultValues: { ...provider } });
const output = useWatch<AuthenticationProvider>({ control });
const [providableType, setProvidableType] = useState<ProvidableType>(provider?.providable_type);
const [strategyName, setStrategyName] = useState<string>(provider?.strategy_name);
const { t } = useTranslation('admin');
useEffect(() => {
updateStrategyName(output as AuthenticationProvider);
}, [output?.providable_type, output?.name]);
/**
* Callback triggered when the form is submitted: process with the provider creation or update.
*/
const onSubmit: SubmitHandler<AuthenticationProvider> = (data: AuthenticationProvider) => {
AuthProviderAPI[action](data).then(() => {
onSuccess(t(`app.admin.authentication.provider_form.${action}_success`));
}).catch(error => {
onError(error);
});
};
/**
* Build the list of available authentication methods to match with react-select requirements.
*/
const buildProvidableTypeOptions = (): Array<selectProvidableTypeOption> => {
return Object.keys(METHODS).map((method: string) => {
return { value: method, label: t(`app.admin.authentication.provider_form.methods.${METHODS[method]}`) };
});
};
/**
* Callback triggered when the providable type is changed.
* Changing the providable type will change the form to match the new type.
*/
const onProvidableTypeChange = (type: ProvidableType) => {
setProvidableType(type);
};
/**
* Request the API the strategy name for the current "in-progress" provider.
*/
const updateStrategyName = useCallback(_debounce((provider: AuthenticationProvider): void => {
AuthProviderAPI.strategyName(provider).then(strategyName => {
setStrategyName(strategyName);
}).catch(error => {
onError(error);
});
}, 400), []);
return (
<form className="provider-form" onSubmit={handleSubmit(onSubmit)}>
<FormInput id="name"
register={register}
readOnly={action === 'update'}
rules={{ required: true }}
label={t('app.admin.authentication.provider_form.name')} />
<FormSelect id="providable_type"
control={control}
options={buildProvidableTypeOptions()}
label={t('app.admin.authentication.provider_form.authentication_type')}
onChange={onProvidableTypeChange}
readOnly={action === 'update'}
rules={{ required: true }} />
{providableType === 'DatabaseProvider' && <DatabaseForm register={register} />}
{providableType === 'OAuth2Provider' && <Oauth2Form register={register} strategyName={strategyName} />}
{providableType === 'OpenIdConnectProvider' && <OpenidConnectForm register={register}
control={control}
currentFormValues={output.providable_attributes as OpenIdConnectProvider}
formState={formState}
setValue={setValue} />}
{providableType && providableType !== 'DatabaseProvider' && <DataMappingForm register={register}
control={control}
providerType={providableType}
setValue={setValue}
currentFormValues={output.auth_provider_mappings_attributes as Array<AuthenticationProviderMapping>} />}
<div className="main-actions">
<FabButton type="submit" className="submit-button">{t('app.admin.authentication.provider_form.save')}</FabButton>
</div>
</form>
);
};
const ProviderFormWrapper: React.FC<ProviderFormProps> = ({ action, provider, onError, onSuccess }) => {
return (
<Loader>
<ProviderForm action={action} provider={provider} onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('providerForm', react2angular(ProviderFormWrapper, ['action', 'provider', 'onSuccess', 'onError']));

View File

@ -0,0 +1,50 @@
import React from 'react';
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { FormInput } from '../form/form-input';
export interface StringMappingFormProps<TFieldValues, TContext extends object> {
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
}
/**
* Partial form to map an internal string field to an external API.
*/
export const StringMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: StringMappingFormProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
return (
<div className="string-mapping-form array-mapping-form">
<h4>{t('app.admin.authentication.string_mapping_form.mappings')}</h4>
<div className="mapping-actions">
<FabButton
icon={<i className="fa fa-plus" />}
onClick={() => append({})} />
</div>
{fields.map((item, index) => (
<div key={item.id} className="mapping-item">
<div className="inputs">
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
register={register}
rules={{ required: true }}
label={t('app.admin.authentication.string_mapping_form.mapping_from')} />
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
register={register}
rules={{ required: true }}
label={t('app.admin.authentication.string_mapping_form.mapping_to')} />
</div>
<div className="actions">
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
</div>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { FabModal } from '../base/fab-modal';
import { useTranslation } from 'react-i18next';
import { IntegerMappingForm } from './integer-mapping-form';
import { UseFormRegister } from 'react-hook-form';
import { Control } from 'react-hook-form/dist/types/form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { mappingType } from '../../models/authentication-provider';
import { BooleanMappingForm } from './boolean-mapping-form';
import { DateMappingForm } from './date-mapping-form';
import { StringMappingForm } from './string-mapping-form';
interface TypeMappingModalProps<TFieldValues, TContext extends object> {
model: string,
field: string,
type: mappingType,
isOpen: boolean,
toggleModal: () => void,
register: UseFormRegister<TFieldValues>,
control: Control<TFieldValues, TContext>,
fieldMappingId: number,
}
/**
* Modal dialog to display the expected type for the current data field.
* Also allows to map the incoming data (from the authentication provider API) to the expected type/data.
*
* This component is intended to be used in a react-hook-form context.
*/
export const TypeMappingModal = <TFieldValues extends FieldValues, TContext extends object>({ model, field, type, isOpen, toggleModal, register, control, fieldMappingId }:TypeMappingModalProps<TFieldValues, TContext>) => {
const { t } = useTranslation('admin');
return (
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
className="type-mapping-modal"
title={t('app.admin.authentication.type_mapping_modal.data_mapping')}
confirmButton={<i className="fa fa-check" />}
onConfirm={toggleModal}>
<span>{model} &gt; {field} ({t('app.admin.authentication.type_mapping_modal.TYPE_expected', { TYPE: t(`app.admin.authentication.type_mapping_modal.types.${type}`) })})</span>
{type === 'integer' && <IntegerMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
{type === 'boolean' && <BooleanMappingForm register={register} fieldMappingId={fieldMappingId} />}
{type === 'date' && <DateMappingForm control={control} fieldMappingId={fieldMappingId} />}
{type === 'string' && <StringMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
</FabModal>
);
};

View File

@ -7,12 +7,13 @@ interface FabButtonProps {
disabled?: boolean,
type?: 'submit' | 'reset' | 'button',
form?: string,
tooltip?: string,
}
/**
* This component is a template for a clickable button that wraps the application style
*/
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, children }) => {
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, tooltip, children }) => {
/**
* Check if the current component was provided an icon to display
*/
@ -37,7 +38,7 @@ export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className,
};
return (
<button type={type} form={form} onClick={handleClick} disabled={disabled} className={`fab-button ${className || ''}`}>
<button type={type} form={form} onClick={handleClick} disabled={disabled} className={`fab-button ${className || ''}`} title={tooltip}>
{hasIcon() && <span className={hasChildren() ? 'fab-button--icon' : 'fab-button--icon-only'}>{icon}</span>}
{children}
</button>

View File

@ -0,0 +1,44 @@
import React from 'react';
interface FabOutputCopyProps {
text: string,
onCopy?: () => void,
label?: string,
}
/**
* This component shows a read-only input text filled with the provided text. A button allows to copy the text to the clipboard.
*/
export const FabOutputCopy: React.FC<FabOutputCopyProps> = ({ label, text, onCopy }) => {
const [copied, setCopied] = React.useState(false);
/**
* Copy the given text to the clipboard.
*/
const textToClipboard = () => {
if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1000);
if (onCopy) {
onCopy();
}
});
}
};
return (
<div className="fab-output-copy">
<label className="form-item">
<div className='form-item-header'>
<p>{label}</p>
</div>
<div className='form-item-field'>
<input value={text} readOnly />
<span className="addon">
<button className={copied ? 'copied' : ''} onClick={textToClipboard}><i className="fa fa-clipboard" /></button>
</span>
</div>
</label>
</div>
);
};

View File

@ -1,73 +0,0 @@
import React, { ReactNode } from 'react';
import { FieldErrors, FieldPathValue, UseFormRegister, Validate } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
type inputType = string|number|readonly string [];
type ruleTypes<TFieldValues> = {
required?: boolean | string,
pattern?: RegExp | {value: RegExp, message: string},
minLenght?: number,
maxLenght?: number,
min?: number,
max?: number,
validate?: Validate<TFieldValues>;
};
interface RHFInputProps<TFieldValues> {
id: string,
register: UseFormRegister<TFieldValues>,
label?: string,
tooltip?: string,
defaultValue?: inputType,
icon?: ReactNode,
addOn?: ReactNode,
addOnClassName?: string,
classes?: string,
rules?: ruleTypes<TFieldValues>,
readOnly?: boolean,
disabled?: boolean,
placeholder?: string,
error?: FieldErrors,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
step?: number | 'any'
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const RHFInput = <TFieldValues extends FieldValues>({ id, register, label, tooltip, defaultValue, icon, classes, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, step }: RHFInputProps<TFieldValues>) => {
// Compose classnames from props
const classNames = `
rhf-input ${classes || ''}
${error && error[id] ? 'is-incorrect' : ''}
${rules && rules.required ? 'is-required' : ''}
${readOnly ? 'is-readOnly' : ''}
${disabled ? 'is-disabled' : ''}`;
return (
<label className={classNames}>
{label && <div className='rhf-input-header'>
<p>{label}</p>
{/* TODO: Create tooltip component */}
{tooltip && <span>{tooltip}</span>}
</div>}
<div className='rhf-input-field'>
{icon && <span className="icon">{icon}</span>}
<input id={id}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>
})}
type={type}
step={step}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder} />
{addOn && <span className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
</div>
{(error && error[id]) && <div className="rhf-input-error">{error[id].message}</div> }
</label>
);
};

View File

@ -0,0 +1,8 @@
# components/from
This directory is holding the inputs components for usage within forms controlled by [React-hook-form](https://react-hook-form.com/).
All these components must have [props](https://reactjs.org/docs/components-and-props.html) that inherits from [FormComponent](../models/form-component.ts)
or from [FormControlledComponent](../models/form-component.ts).
Please look at the existing components for examples.

View File

@ -0,0 +1,87 @@
import React, { InputHTMLAttributes, ReactNode, useCallback, useEffect, useState } from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce, get as _get } from 'lodash';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FormComponent } from '../../models/form-component';
interface FormInputProps<TFieldValues> extends InputHTMLAttributes<HTMLInputElement>, FormComponent<TFieldValues>{
id: string,
label?: string,
tooltip?: ReactNode,
icon?: ReactNode,
addOn?: ReactNode,
addOnClassName?: string,
debounce?: number,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues>({ id, register, label, tooltip, defaultValue, icon, className, rules, readOnly, disabled, type, addOn, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce }: FormInputProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState(false);
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
}, [formState]);
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
const debouncedOnChange = debounce ? useCallback(_debounce(onChange, debounce), [debounce]) : null;
/**
* Handle the change of content in the input field, and trigger the parent callback, if any
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (typeof onChange === 'function') {
if (debouncedOnChange) {
debouncedOnChange(e);
} else {
onChange(e);
}
}
};
// Compose classnames from props
const classNames = [
'form-input form-item',
`${className || ''}`,
`${type === 'hidden' ? 'is-hidden' : ''}`,
`${isDirty && error && error[id] ? 'is-incorrect' : ''}`,
`${isDirty && warning && warning[id] ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${readOnly ? 'is-readonly' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
return (
<label className={classNames}>
{label && <div className='form-item-header'>
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className='form-item-field'>
{icon && <span className="icon">{icon}</span>}
<input id={id}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}
type={type}
step={step}
disabled={disabled}
readOnly={readOnly}
placeholder={placeholder} />
{addOn && <span className={`addon ${addOnClassName || ''}`}>{addOn}</span>}
</div>
{(isDirty && error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
{(isDirty && warning && warning[id]) && <div className="form-item-warning">{warning[id].message}</div> }
</label>
);
};

View File

@ -0,0 +1,81 @@
import React, { ReactNode } from 'react';
import Select from 'react-select';
import { Controller, Path } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { FormControlledComponent } from '../../models/form-component';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext> {
id: string,
label?: string,
tooltip?: ReactNode,
options: Array<selectOption<TOptionValue>>,
valuesDefault?: Array<TOptionValue>,
onChange?: (values: Array<TOptionValue>) => void,
className?: string,
placeholder?: string,
disabled?: boolean,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption<TOptionValue> = { value: TOptionValue, label: string };
/**
* This component is a wrapper around react-select to use with react-hook-form.
* It is a multi-select component.
*/
export const FormMultiSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valuesDefault, error, rules, disabled, onChange }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = [
'form-multi-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
* when the selected option changes.
*/
const onChangeCb = (newValues: Array<TOptionValue>): void => {
if (typeof onChange === 'function') {
onChange(newValues);
}
};
return (
<label className={classNames}>
{label && <div className="form-item-header">
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className="form-item-field">
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valuesDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
render={({ field: { onChange, value, ref } }) =>
<Select ref={ref}
classNamePrefix="rs"
className="rs"
value={options.filter(c => value?.includes(c.value))}
onChange={val => {
const values = val?.map(c => c.value);
onChangeCb(values);
onChange(values);
}}
placeholder={placeholder}
options={options}
isMulti />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
);
};

View File

@ -0,0 +1,82 @@
import React, { ReactNode } from 'react';
import Select from 'react-select';
import { Controller, Path } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { FormControlledComponent } from '../../models/form-component';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext> {
id: string,
label?: string,
tooltip?: ReactNode,
options: Array<selectOption<TOptionValue>>,
valueDefault?: TOptionValue,
onChange?: (value: TOptionValue) => void,
className?: string,
placeholder?: string,
disabled?: boolean,
readOnly?: boolean,
clearable?: boolean,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption<TOptionValue> = { value: TOptionValue, label: string };
/**
* This component is a wrapper for react-select to use with react-hook-form
*/
export const FormSelect = <TFieldValues extends FieldValues, TContext extends object, TOptionValue>({ id, label, tooltip, className, control, placeholder, options, valueDefault, error, rules, disabled, onChange, readOnly, clearable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const classNames = [
'form-select form-item',
`${className || ''}`,
`${error && error[id] ? 'is-incorrect' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${disabled ? 'is-disabled' : ''}`
].join(' ');
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
* when the selected option changes.
*/
const onChangeCb = (newValue: TOptionValue): void => {
if (typeof onChange === 'function') {
onChange(newValue);
}
};
return (
<label className={classNames}>
{label && <div className="form-item-header">
<p>{label}</p>
{tooltip && <div className="item-tooltip">
<span className="trigger"><i className="fa fa-question-circle" /></span>
<div className="content">{tooltip}</div>
</div>}
</div>}
<div className="form-item-field">
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
render={({ field: { onChange, value, ref } }) =>
<Select ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
}}
placeholder={placeholder}
isDisabled={readOnly}
isClearable={clearable}
options={options} />
} />
</div>
{(error && error[id]) && <div className="form-item-error">{error[id].message}</div> }
</label>
);
};

View File

@ -5,7 +5,7 @@ import { PlanCategory } from '../../models/plan-category';
import { Loader } from '../base/loader';
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { FabTextEditor } from '../base/text-editor/fab-text-editor';
import { RHFInput } from '../base/rhf-input';
import { FormInput } from '../form/form-input';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
@ -45,13 +45,13 @@ const PlanCategoryFormComponent: React.FC<PlanCategoryFormProps> = ({ action, ca
return (
<form onSubmit={handleSubmit(onSubmit)}>
<RHFInput id='name' register={register} rules={{ required: 'true' }} label={t('app.admin.manage_plan_category.name')} />
<FormInput id='name' register={register} rules={{ required: 'true' }} label={t('app.admin.manage_plan_category.name')} />
<Controller name="description" control={control} render={({ field: { onChange, value } }) =>
<FabTextEditor label={t('app.admin.manage_plan_category.description')} onChange={onChange} content={value} limit={100} />
} />
<RHFInput id='weight' register={register} type='number' label={t('app.admin.manage_plan_category.significance')} />
<FormInput id='weight' register={register} type='number' label={t('app.admin.manage_plan_category.significance')} />
<FabAlert level="info" className="significance-info">
{t('app.admin.manage_plan_category.info')}
</FabAlert>

View File

@ -18,7 +18,8 @@
// list of supported authentication methods
const METHODS = {
DatabaseProvider: 'local_database',
OAuth2Provider: 'o_auth2'
OAuth2Provider: 'o_auth2',
OpenIdConnectProvider: 'openid_connect'
};
/**
@ -31,129 +32,6 @@ const findIdxById = function (elements, id) {
return (elements.map(function (elem) { return elem.id; })).indexOf(id);
};
/**
* For OAuth2 authentications, mapping the user's ID is mandatory. This function will check that this mapping
* is effective and will return false otherwise
* @param mappings {Array<Object>} expected: $scope.provider.providable_attributes.o_auth2_mappings_attributes
* @returns {Boolean} true if the mapping is declared
*/
const check_oauth2_id_is_mapped = function (mappings) {
for (const mapping of Array.from(mappings)) {
if ((mapping.local_model === 'user') && (mapping.local_field === 'uid') && !mapping._destroy) {
return true;
}
}
return false;
};
/**
* Provides a set of common callback methods and data to the $scope parameter. These methods are used
* in the various authentication providers' controllers.
*
* Provides :
* - $scope.authMethods
* - $scope.mappingFields
* - $scope.cancel()
* - $scope.methodName()
* - $scope.defineDataMapping(mapping)
*
* Requires :
* - mappingFieldsPromise: retrieved by AuthProvider.mapping_fields()
* - $state (Ui-Router) [ 'app.admin.members' ]
* - _t : translation method
*/
class AuthenticationController {
constructor ($scope, $state, $uibModal, _t, mappingFieldsPromise) {
// list of supported authentication methods
$scope.authMethods = METHODS;
// list of fields that can be mapped through the SSO
$scope.mappingFields = mappingFieldsPromise;
/**
* Changes the admin's view to the members list page
*/
$scope.cancel = function () { $state.go('app.admin.members'); };
/**
* Return a localized string for the provided method
*/
$scope.methodName = function (method) {
return _t('app.shared.authentication.' + METHODS[method]);
};
/**
* Open a modal allowing to specify the data mapping for the given field
*/
$scope.defineDataMapping = function (mapping) {
$uibModal.open({
templateUrl: '/admin/authentications/_data_mapping.html',
size: 'md',
resolve: {
field () { return mapping; },
datatype () {
for (const field of Array.from($scope.mappingFields[mapping.local_model])) {
if (field[0] === mapping.local_field) {
return field[1];
}
}
}
},
controller: ['$scope', '$uibModalInstance', 'field', 'datatype', function ($scope, $uibModalInstance, field, datatype) {
// parent field
$scope.field = field;
// expected data type
$scope.datatype = datatype;
// data transformation rules
$scope.transformation =
{ rules: field.transformation || { type: datatype } };
// available transformation formats
$scope.formats = {
date: [
{
label: 'ISO 8601',
value: 'iso8601'
},
{
label: 'RFC 2822',
value: 'rfc2822'
},
{
label: 'RFC 3339',
value: 'rfc3339'
},
{
label: 'Timestamp (s)',
value: 'timestamp-s'
},
{
label: 'Timestamp (ms)',
value: 'timestamp-ms'
}
]
};
// Create a new mapping between anything and an expected integer
$scope.addIntegerMapping = function () {
if (!angular.isArray($scope.transformation.rules.mapping)) {
$scope.transformation.rules.mapping = [];
}
return $scope.transformation.rules.mapping.push({ from: '', to: 0 });
};
// close and save the modifications
$scope.ok = function () { $uibModalInstance.close($scope.transformation.rules); };
// do not save the modifications
$scope.cancel = function () { $uibModalInstance.dismiss(); };
}]
})
.result.finally(null).then(function (transfo_rules) { mapping.transformation = transfo_rules; });
};
}
}
/**
* Page listing all authentication providers
*/
@ -229,123 +107,56 @@ Application.Controllers.controller('AuthentificationController', ['$scope', '$st
/**
* Page to add a new authentication provider
*/
Application.Controllers.controller('NewAuthenticationController', ['$scope', '$state', '$rootScope', '$uibModal', 'dialogs', 'growl', 'mappingFieldsPromise', 'authProvidersPromise', 'AuthProvider', '_t',
function ($scope, $state, $rootScope, $uibModal, dialogs, growl, mappingFieldsPromise, authProvidersPromise, AuthProvider, _t) {
$scope.mode = 'creation';
// default parameters for the new authentication provider
$scope.provider = {
name: '',
providable_type: '',
providable_attributes: {}
Application.Controllers.controller('NewAuthenticationController', ['$scope', '$state', 'growl',
function ($scope, $state, growl) {
/**
* Shows a success message forwarded from a child react component
*/
$scope.onSuccess = function (message) {
growl.success(message);
$scope.cancel();
};
/**
* Initialize some provider's specific properties when selecting the provider type
* Callback triggered by react components
*/
$scope.updateProvidable = function () {
// === OAuth2Provider ===
if ($scope.provider.providable_type === 'OAuth2Provider') {
if (typeof $scope.provider.providable_attributes.o_auth2_mappings_attributes === 'undefined') {
return $scope.provider.providable_attributes.o_auth2_mappings_attributes = [];
}
}
};
// Add others providers initializers here if needed ...
/**
* Validate and save the provider parameters in database
*/
$scope.registerProvider = function () {
// === DatabaseProvider ===
let provider;
if ($scope.provider.providable_type === 'DatabaseProvider') {
// prevent from adding mode than 1
for (provider of Array.from(authProvidersPromise)) {
if (provider.providable_type === 'DatabaseProvider') {
growl.error(_t('app.admin.authentication_new.a_local_database_provider_already_exists_unable_to_create_another'));
return false;
}
}
return AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
growl.success(_t('app.admin.authentication_new.local_provider_successfully_saved'));
return $state.go('app.admin.members');
});
// === OAuth2Provider ===
} else if ($scope.provider.providable_type === 'OAuth2Provider') {
// check the ID mapping
if (!check_oauth2_id_is_mapped($scope.provider.providable_attributes.o_auth2_mappings_attributes)) {
growl.error(_t('app.admin.authentication_new.it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
return false;
}
// discourage the use of unsecure SSO
if (!($scope.provider.providable_attributes.base_url.indexOf('https://') > -1)) {
dialogs.confirm(
{
size: 'l',
resolve: {
object () {
return {
title: _t('app.admin.authentication_new.security_issue_detected'),
msg: _t('app.admin.authentication_new.beware_the_oauth2_authenticatoin_provider_you_are_about_to_add_isnt_using_HTTPS') +
_t('app.admin.authentication_new.this_is_a_serious_security_issue_on_internet_and_should_never_be_used_except_for_testing_purposes') +
_t('app.admin.authentication_new.do_you_really_want_to_continue')
};
}
}
},
function () { // unsecured http confirmed
AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
growl.success(_t('app.admin.authentication_new.unsecured_oauth2_provider_successfully_added'));
return $state.go('app.admin.members');
});
}
);
} else {
AuthProvider.save({ auth_provider: $scope.provider }, function (provider) {
growl.success(_t('app.admin.authentication_new.oauth2_provider_successfully_added'));
return $state.go('app.admin.members');
});
}
}
$scope.onError = function (message) {
growl.error(message);
};
// Using the AuthenticationController
return new AuthenticationController($scope, $state, $uibModal, _t, mappingFieldsPromise);
$scope.cancel = function () { $state.go('app.admin.members'); };
}
]);
/**
* Page to edit an already added authentication provider
*/
Application.Controllers.controller('EditAuthenticationController', ['$scope', '$state', '$rootScope', '$uibModal', 'dialogs', 'growl', 'providerPromise', 'mappingFieldsPromise', 'AuthProvider', '_t',
function ($scope, $state, $rootScope, $uibModal, dialogs, growl, providerPromise, mappingFieldsPromise, AuthProvider, _t) {
Application.Controllers.controller('EditAuthenticationController', ['$scope', '$state', 'growl', 'providerPromise',
function ($scope, $state, growl, providerPromise) {
// parameters of the currently edited authentication provider
$scope.provider = providerPromise;
$scope.mode = 'edition';
$scope.provider = cleanProvider(providerPromise);
/**
* Update the current provider with the new inputs
* Shows a success message forwarded from a child react component
*/
$scope.updateProvider = function () {
// check the ID mapping
if (!check_oauth2_id_is_mapped($scope.provider.providable_attributes.o_auth2_mappings_attributes)) {
growl.error(_t('app.admin.authentication_edit.it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider'));
return false;
}
return AuthProvider.update(
{ id: $scope.provider.id },
{ auth_provider: $scope.provider },
function (provider) {
growl.success(_t('app.admin.authentication_edit.provider_successfully_updated'));
$state.go('app.admin.members');
},
function () { growl.error(_t('app.admin.authentication_edit.an_error_occurred_unable_to_update_the_provider')); }
);
$scope.onSuccess = function (message) {
growl.success(message);
};
// Using the AuthenticationController
return new AuthenticationController($scope, $state, $uibModal, _t, mappingFieldsPromise);
/**
* Callback triggered by react components
*/
$scope.onError = function (message) {
growl.error(message);
};
$scope.cancel = function () { $state.go('app.admin.members'); };
// prepare the provider for the react-hook-form
function cleanProvider (provider) {
delete provider.$promise;
delete provider.$resolved;
return provider;
}
}
]);

View File

@ -1,16 +1,3 @@
/* eslint-disable
handle-callback-err,
no-return-assign,
no-undef,
standard/no-callback-literal,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
Application.Controllers.controller('ApplicationController', ['$rootScope', '$scope', '$transitions', '$window', '$locale', '$timeout', 'Session', 'AuthService', 'Auth', '$uibModal', '$state', 'growl', 'Notification', '$interval', 'Setting', '_t', 'Version', 'Help',
function ($rootScope, $scope, $transitions, $window, $locale, $timeout, Session, AuthService, Auth, $uibModal, $state, growl, Notification, $interval, Setting, _t, Version, Help) {
@ -83,16 +70,16 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
*/
$scope.signup = function (e) {
if (e) { e.preventDefault(); }
<% active_provider = AuthProvider.active %>
<% if active_provider.providable_type != DatabaseProvider.name %>
if (Fablab.activeProviderType !== 'DatabaseProvider') {
$window.location.href = '/sso-redirect';
<% else %>
} else {
return $uibModal.open({
templateUrl: '/shared/signupModal.html',
size: 'md',
resolve: {
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['phone_required', 'recaptcha_site_key', 'confirmation_required', 'address_required']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) {
return Setting.query({ names: "['phone_required', 'recaptcha_site_key', 'confirmation_required', 'address_required']" }).$promise;
}]
},
controller: ['$scope', '$uibModalInstance', 'Group', 'CustomAsset', 'settingsPromise', 'growl', '_t', function ($scope, $uibModalInstance, Group, CustomAsset, settingsPromise, growl, _t) {
// default parameters for the date picker in the account creation modal
@ -118,13 +105,15 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
$scope.openDatePicker = function ($event) {
$event.preventDefault();
$event.stopPropagation();
return $scope.datePicker.opened = true;
$scope.datePicker.opened = true;
};
// retrieve the groups (standard, student ...)
Group.query(function (groups) {
$scope.groups = groups;
$scope.enabledGroups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
$scope.enabledGroups = groups.filter(function (g) {
return (g.slug !== 'admins') && !g.disabled;
});
});
// retrieve the CGU
@ -157,7 +146,10 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
return Auth.register($scope.user).then(function (user) {
if (user.id) {
// creation successful
$uibModalInstance.close({ user, settings: settingsPromise });
$uibModalInstance.close({
user,
settings: settingsPromise
});
} else {
// the user was not saved in database, something wrong occurred
growl.error(_t('app.public.common.unexpected_error_occurred'));
@ -178,7 +170,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
});
};
}]
}).result['finally'](null).then(function (res) {
}).result.finally(null).then(function (res) {
// when the account was created successfully, set the session to the newly created account
if (res.settings.confirmation_required === 'true') {
Auth._currentUser = null;
@ -187,7 +179,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
$scope.setCurrentUser(res.user);
}
});
<% end %>
}
};
/**
@ -205,7 +197,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
$scope.alerts.splice(index, 1);
};
return $scope.changePassword = function () {
$scope.changePassword = function () {
$scope.alerts = [];
return $http.put('/users/password.json', { user: $scope.user }).then(function () { $uibModalInstance.close(); }).catch(function (data) {
angular.forEach(data.data.errors, function (v, k) {
@ -219,7 +211,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
});
};
}]
}).result['finally'](null).then(function () {
}).result.finally(null).then(function () {
growl.success(_t('app.public.common.your_password_was_successfully_changed'));
return Auth.login().then(function (user) {
$scope.setCurrentUser(user);
@ -244,7 +236,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
let toggler = $(event.target);
if (!toggler.data('toggle')) { toggler = toggler.closest('[data-toggle^="class"]'); }
const $class = toggler.data()['toggle'];
const $class = toggler.data().toggle;
const $target = toggler.data('target') || toggler.attr('data-link');
if ($class) {
@ -267,7 +259,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
while (patt.test(cn)) {
cn = cn.replace(patt, ' ');
}
return it.className = $.trim(cn);
it.className = $.trim(cn);
});
}
@ -292,7 +284,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
version () { return $scope.version; }
}
});
}
};
/**
* Trigger the contextual help "feature tour".
@ -303,10 +295,10 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
// we wrap the event triggering into a $timeout to prevent conflicting with current $apply
$timeout(function () {
var evt = new KeyboardEvent('keydown', { key: 'F1' });
const evt = new KeyboardEvent('keydown', { key: 'F1' });
window.dispatchEvent(evt);
});
}
};
/* PRIVATE SCOPE */
/**
@ -405,16 +397,17 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
* Open the modal window allowing the user to log in.
*/
const openLoginModal = function (toState, toParams, callback) {
<% active_provider = AuthProvider.active %>
<% if active_provider.providable_type != DatabaseProvider.name %>
if (Fablab.activeProviderType !== 'DatabaseProvider') {
$window.location.href = '/sso-redirect';
<% else %>
} else {
return $uibModal.open({
templateUrl: '/shared/deviseModal.html',
backdrop: 'static',
size: 'sm',
resolve: {
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['confirmation_required']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) {
return Setting.query({ names: "['confirmation_required']" }).$promise;
}]
},
controller: ['$scope', '$uibModalInstance', '_t', 'settingsPromise', function ($scope, $uibModalInstance, _t, settingsPromise) {
const user = ($scope.user = {});
@ -440,7 +433,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
});
};
// handle modal behaviors. The provided reason will be used to define the following actions
$scope.dismiss = function () { $uibModalInstance.dismiss('cancel'); };
$scope.dismiss = function () {
$uibModalInstance.dismiss('cancel');
};
$scope.openSignup = function (e) {
e.preventDefault();
@ -457,7 +452,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
return $uibModalInstance.dismiss('resetPassword');
};
}]
}).result['finally'](null).then(function (user) {
}).result.finally(null).then(function (user) {
// what to do when the modal is closed
// authentication succeeded, set the session, gather the notifications and redirect
@ -479,9 +474,11 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
size: 'sm',
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
$scope.user = { email: '' };
return $scope.sendReset = function () {
$scope.sendReset = function () {
$scope.alerts = [];
return $http.post('/users/password.json', { user: $scope.user }).then(function () { $uibModalInstance.close(); }).catch(function () {
return $http.post('/users/password.json', { user: $scope.user }).then(function () {
$uibModalInstance.close();
}).catch(function () {
$scope.alerts.push({
msg: _t('app.public.common.your_email_address_is_unknown'),
type: 'danger'
@ -489,7 +486,9 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
});
};
}]
}).result['finally'](null).then(function () { growl.info(_t('app.public.common.you_will_receive_in_a_moment_an_email_with_instructions_to_reset_your_password')); });
}).result.finally(null).then(function () {
growl.info(_t('app.public.common.you_will_receive_in_a_moment_an_email_with_instructions_to_reset_your_password'));
});
} else if (reason === 'confirmationNew') {
// open the 'reset password' modal
return $uibModal.open({
@ -497,9 +496,11 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
size: 'sm',
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
$scope.user = { email: '' };
return $scope.submitConfirmationNewForm = function () {
$scope.submitConfirmationNewForm = function () {
$scope.alerts = [];
return $http.post('/users/confirmation.json', { user: $scope.user }).then(function () { $uibModalInstance.close(); }).catch(function (res) {
return $http.post('/users/confirmation.json', { user: $scope.user }).then(function () {
$uibModalInstance.close();
}).catch(function (res) {
$scope.alerts.push({
msg: res.data.errors.email[0],
type: 'danger'
@ -507,14 +508,15 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
});
};
}]
}).result['finally'](null).then(function () { growl.info(_t('app.public.common.you_will_receive_confirmation_instructions_by_email_detailed')); });
}).result.finally(null).then(function () {
growl.info(_t('app.public.common.you_will_receive_confirmation_instructions_by_email_detailed'));
});
}
});
// otherwise the user just closed the modal
<% end %>
}
};
/**
* Detect if the current page (tab/window) is active of put as background.
* When the status changes, the callback is triggered with the new status as parameter
@ -538,7 +540,8 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
if (evt.type in evtMap) {
if (typeof callback === 'function') { callback(evtMap[evt.type]); }
} else {
if (typeof callback === 'function') { callback(this[hidden] ? 'hidden' : 'visible'); }
const visibility = this[hidden] ? 'hidden' : 'visible';
if (typeof callback === 'function') { callback(visibility); }
}
};
@ -579,5 +582,5 @@ Application.Controllers.controller('VersionModalController', ['$scope', '$uibMod
// callback to close the modal
$scope.close = function () {
$uibModalInstance.dismiss();
}
};
}]);

View File

@ -0,0 +1,67 @@
export type ProvidableType = 'DatabaseProvider' | 'OAuth2Provider' | 'OpenIdConnectProvider';
export interface AuthenticationProvider {
id?: number,
name: string,
status: 'active' | 'previous' | 'pending'
providable_type: ProvidableType,
strategy_name: string
auth_provider_mappings_attributes: Array<AuthenticationProviderMapping>,
providable_attributes?: OAuth2Provider | OpenIdConnectProvider
}
export type mappingType = 'string' | 'text' | 'date' | 'integer' | 'boolean';
export interface AuthenticationProviderMapping {
id?: number,
local_model: 'user' | 'profile',
local_field: string,
api_field: string,
api_endpoint: string,
api_data_type: 'json',
transformation: {
type: mappingType,
format: 'iso8601' | 'rfc2822' | 'rfc3339' | 'timestamp-s' | 'timestamp-ms',
true_value: string,
false_value: string,
mapping: {
from: string,
to: number|string
}
}
}
export interface OAuth2Provider {
id?: string,
base_url: string,
token_endpoint: string,
authorization_endpoint: string,
profile_url: string,
client_id: string,
client_secret: string,
scopes: string
}
export interface OpenIdConnectProvider {
id?: string,
issuer: string,
discovery: boolean,
client_auth_method?: 'basic' | 'jwks',
scope?: string,
prompt?: 'none' | 'login' | 'consent' | 'select_account',
send_scope_to_token_endpoint?: string,
client__identifier: string,
client__secret: string,
client__redirect_uri?: string,
client__authorization_endpoint?: string,
client__token_endpoint?: string,
client__userinfo_endpoint?: string,
client__jwks_uri?: string,
client__end_session_endpoint?: string,
profile_url?: string
}
export interface MappingFields {
user: Array<[string, mappingType]>,
profile: Array<[string, mappingType]>
}

View File

@ -0,0 +1,28 @@
import { FieldErrors, UseFormRegister, Validate } from 'react-hook-form';
import { Control, FormState } from 'react-hook-form/dist/types/form';
export type ruleTypes<TFieldValues> = {
required?: boolean | string,
pattern?: RegExp | { value: RegExp, message: string },
minLength?: number,
maxLength?: number,
min?: number,
max?: number,
validate?: Validate<TFieldValues>;
};
export interface FormComponent<TFieldValues> {
register: UseFormRegister<TFieldValues>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
formState?: FormState<TFieldValues>;
}
export interface FormControlledComponent<TFieldValues, TContext extends object> {
control: Control<TFieldValues, TContext>,
error?: FieldErrors,
warning?: FieldErrors,
rules?: ruleTypes<TFieldValues>,
formState?: FormState<TFieldValues>;
}

View File

@ -0,0 +1,15 @@
export interface OpenIdConfiguration {
authorization_endpoint: string,
token_endpoint: string,
userinfo_endpoint: string,
jwks_uri: string,
registration_endpoint: string,
scopes_supported: string[],
response_types_supported: string[],
response_modes_supported: string[],
grant_types_supported: string[],
subject_types_supported: string[],
id_token_signing_alg_values_supported: string[],
code_challenge_methods_supported: string[]
}

View File

@ -1002,10 +1002,6 @@ angular.module('application.router', ['ui.router'])
templateUrl: '/admin/authentications/new.html',
controller: 'NewAuthenticationController'
}
},
resolve: {
mappingFieldsPromise: ['AuthProvider', function (AuthProvider) { return AuthProvider.mapping_fields().$promise; }],
authProvidersPromise: ['AuthProvider', function (AuthProvider) { return AuthProvider.query().$promise; }]
}
})
.state('app.admin.authentication_edit', {
@ -1017,8 +1013,7 @@ angular.module('application.router', ['ui.router'])
}
},
resolve: {
providerPromise: ['AuthProvider', '$transition$', function (AuthProvider, $transition$) { return AuthProvider.get({ id: $transition$.params().id }).$promise; }],
mappingFieldsPromise: ['AuthProvider', function (AuthProvider) { return AuthProvider.mapping_fields().$promise; }]
providerPromise: ['AuthProvider', '$transition$', function (AuthProvider, $transition$) { return AuthProvider.get({ id: $transition$.params().id }).$promise; }]
}
})

View File

@ -15,15 +15,21 @@
@import "app.components";
@import "app.plugins";
@import "modules/authentication-provider/array-mapping-form";
@import "modules/authentication-provider/data-mapping-form";
@import "modules/authentication-provider/openid-connect-data-mapping-form";
@import "modules/authentication-provider/provider-form";
@import "modules/authentication-provider/type-mapping-modal";
@import "modules/base/fab-alert";
@import "modules/base/fab-button";
@import "modules/base/fab-input";
@import "modules/base/fab-modal";
@import "modules/base/fab-output-copy";
@import "modules/base/fab-popover";
@import "modules/base/fab-text-editor";
@import "modules/base/labelled-input";
@import "modules/calendar/calendar";
@import "modules/base/rhf-input";
@import "modules/form/form-item";
@import "modules/machines/machine-card";
@import "modules/machines/machines-filters";
@import "modules/machines/machines-list";
@ -83,4 +89,4 @@
@import "app.responsive";
@import "overrides"
@import "overrides";

View File

@ -0,0 +1,28 @@
.array-mapping-form {
.mapping-actions {
display: flex;
flex-direction: row-reverse;
margin-right: 1rem;
}
.mapping-item {
margin: 1rem 1rem 2rem;
border: 1px solid var(--gray-soft-dark);
border-radius: 8px;
padding: 1em;
display: flex;
flex-direction: row;
align-items: center;
.inputs {
width: 85%;
}
.actions {
padding: 1em;
.delete-button {
background-color: #cb1117;
color: white;
}
}
}
}

View File

@ -0,0 +1,10 @@
.data-mapping-form {
.mapping-item .inputs .form-item {
margin-left: 20px;
}
.local-data,
.remote-data > *{
display: flex;
flex-direction: row;
}
}

View File

@ -0,0 +1,7 @@
.openid-connect-data-mapping-form {
.auto-configure-button {
align-self: center;
margin-top: 0.8rem;
margin-left: 20px;
}
}

View File

@ -0,0 +1,20 @@
.provider-form {
.main-actions {
display: flex;
flex-direction: row-reverse;
.submit-button {
margin-top: 2em;
border-color: var(--information-light);
background-color: var(--information);
color: white;
&:hover {
border-color: var(--information);
background-color: var(--information-dark);
color: white;
}
}
}
}

View File

@ -0,0 +1,15 @@
.type-mapping-modal {
.fab-modal-footer {
.modal-btn--confirm {
border-color: var(--information-light);
background-color: var(--information);
color: white;
&:hover {
border-color: var(--information);
background-color: var(--information-dark);
color: white;
}
}
}
}

View File

@ -0,0 +1,22 @@
.fab-output-copy {
.form-item-field {
& > input {
background-color: var(--gray-soft);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
& > .addon > button.copied {
position: relative;
&:after {
content: '\f00c';
font-family: 'Font Awesome 5 Free';
position: absolute;
right: 0;
width: 100%;
background-color: #EFEFEF;
}
}
}
}

View File

@ -1,78 +0,0 @@
.rhf-input {
width: 100%;
margin-bottom: 1.6rem;
&-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.8rem;
p {
@include text-sm;
margin: 0;
}
}
&.is-required &-header p::after {
content: "*";
margin-left: 0.5ch;
color: var(--error);
}
&-field {
height: 4rem;
display: grid;
grid-template-areas: "icon input addon";
grid-template-columns: min-content 1fr min-content;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
overflow: hidden;
transition: border-color ease-in-out 0.15s;
.icon,
.addon {
width: 4rem;
display: flex;
justify-content: center;
align-items: center;
color: var(--gray-hard-light);
background-color: var(--gray-soft);
}
.icon {
grid-area: icon;
border-right: 1px solid var(--gray-soft-dark);
}
input {
grid-area: input;
border: none;
border-radius: var(--border-radius);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .08);
padding: 0 0.8rem;
color: var(--gray-hard-darkest);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.addon {
grid-area: addon;
border-left: 1px solid var(--gray-soft-dark);
}
}
&.is-incorrect &-field {
border-color: var(--error);
.icon {
color: var(--error);
border-color: var(--error);
background-color: var(--error-lightest);
}
}
&.is-disabled &-field input,
&.is-readOnly &-field input {
background-color: var(--gray-soft-light);
}
&-error {
margin-top: 0.4rem;
color: var(--error);
}
}

View File

@ -0,0 +1,164 @@
.form-item {
width: 100%;
margin-bottom: 1.6rem;
&-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.8rem;
position: relative;
p {
@include text-sm;
margin: 0;
}
.item-tooltip {
position: relative;
cursor: pointer;
.trigger i { display: block; }
.content {
position: absolute;
top: 0;
right: 0;
display: none;
width: max-content;
max-width: min(75vw, 65ch);
padding: 1rem;
background-color: var(--information-lightest);
color: var(--information);
border: 1px solid var(--information);
border-radius: 8px;
font-size: 14px;
font-weight: normal;
line-height: 1.2em;
z-index: 1;
& > span { display: block; }
a {
color: var(--gray-hard);
text-decoration: underline;
}
}
&:hover .content { display: block; }
}
}
&.is-hidden {
display: none;
}
&.is-required &-header p::after {
content: "*";
margin-left: 0.5ch;
color: var(--error);
}
&-field {
min-height: 4rem;
display: grid;
grid-template-areas: "icon field addon";
grid-template-columns: min-content minmax(0, 1fr) min-content;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
transition: border-color ease-in-out 0.15s;
.icon,
.addon {
width: 4rem;
display: flex;
justify-content: center;
align-items: center;
color: var(--gray-hard-light);
background-color: var(--gray-soft);
}
.icon {
grid-area: icon;
border-right: 1px solid var(--gray-soft-dark);
}
& > input {
grid-area: field;
border: none;
border-radius: var(--border-radius);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .08);
padding: 0 0.8rem;
color: var(--gray-hard-darkest);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&::placeholder {
font-weight: 400;
color: var(--gray-soft-darkest);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--information);
}
}
// override React-select component's style
.rs {
grid-area: field;
&__control {
border: none;
border-radius: var(--border-radius);
&--is-focused {
box-shadow: 0 0 0 2px var(--information);
}
}
&__menu {
margin-top: 1px;
border: 1px solid var(--gray-soft-dark);
box-shadow: var(--shadow);
}
&__option {
color: #000000;
&:hover {
background-color: var(--information-lightest);
}
&--is-selected,
&--is-selected:hover {
background-color: var(--information-light);
}
}
&__placeholder {
font-weight: 400;
color: var(--gray-soft-darkest);
}
}
.addon {
grid-area: addon;
border-left: 1px solid var(--gray-soft-dark);
}
}
&.is-incorrect &-field {
border-color: var(--error);
.icon {
color: var(--error);
border-color: var(--error);
background-color: var(--error-lightest);
}
}
&.is-warned &-field {
border-color: var(--warning);
.icon {
color: var(--warning);
border-color: var(--warning);
background-color: var(--warning-lightest);
}
}
&.is-disabled &-field input,
&.is-readOnly &-field input {
background-color: var(--gray-soft-light);
}
&-error {
margin-top: 0.4rem;
color: var(--error);
}
&-warning {
margin-top: 0.4rem;
color: var(--warning);
}
}

View File

@ -1,65 +0,0 @@
<div class="modal-header">
<h3 class="modal-title"><span translate>{{ 'app.shared.authentication.data_mapping' }}</span> : {{field.local_field}}</h3>
</div>
<div class="modal-body m-lg">
<div>
<span translate>{{ 'app.shared.authentication.expected_data_type' }}</span> : {{datatype}}
</div>
<form name="mappingForm" class="m-t-md">
<ng-switch on="datatype">
<!-- BOOLEAN -->
<div ng-switch-when="boolean">
<label for="add_mapping" translate>{{ 'app.shared.authentication.mappings' }}</label>
<ul class="list-unstyled">
<li class="m-t-sm m-l">
<input type="text"
name="true_value"
id="true_value"
class="form-control inline width-35 m-r "
ng-model="transformation.rules.false_value">
<i class="fa fa-arrows-h"></i>
<label for="true_value" class="m-l">true</label>
</li>
<li class="m-t-sm m-l">
<input type="text"
name="false_value"
id="false_value"
class="form-control inline width-35 m-r "
ng-model="transformation.rules.true_value">
<i class="fa fa-arrows-h"></i>
<label for="false_value" class="m-l">false</label>
</li>
</ul>
</div>
<!-- DATE -->
<div ng-switch-when="date">
<label for="date_format" translate>{{ 'app.shared.authentication.input_format' }}</label>
<select name="date_format"
id="date_format"
class="form-control"
ng-model="transformation.rules.format"
ng-options="format.value as format.label for format in formats.date">
</select>
</div>
<!-- INTEGER -->
<div ng-switch-when="integer">
<label for="add_mapping" translate>{{ 'app.shared.authentication.mappings' }}</label>
<button class="btn btn-default pull-right" ng-click="addIntegerMapping()"><i class="fa fa-plus"></i></button>
<ul class="list-unstyled">
<li ng-repeat="map in transformation.rules.mapping" class="m-t-sm m-l">
<input type="text" class="form-control inline width-35 m-r " ng-model="map.from">
<i class="fa fa-arrows-h"></i>
<input type="number" class="form-control inline width-35 m-l" ng-model="map.to">
</li>
</ul>
</div>
</ng-switch>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-primary" ng-click="ok()" ng-disabled="!mappingForm.$valid" ng-if="datatype != 'string' && datatype != 'text'" translate>{{ 'app.shared.buttons.confirm' }}</button>
<button class="btn btn-warning" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
</div>

View File

@ -1,30 +0,0 @@
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[name]'].$dirty && providerForm['auth_provider[name]'].$invalid}">
<label for="provider_name" class="col-sm-3 control-label" translate>{{ 'app.shared.authentication.name' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.name"
class="form-control"
name="auth_provider[name]"
id="provider_name"
ng-disabled="mode == 'edition'"
required />
<span class="help-block" ng-show="providerForm['auth_provider[name]'].$dirty && providerForm['auth_provider[name]'].$error.required" translate>{{ 'app.shared.authentication.provider_name_is_required' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[providable_type]'].$dirty && providerForm['auth_provider[providable_type]'].$invalid}">
<label for="provider_type" class="col-sm-3 control-label" translate>{{ 'app.shared.authentication.authentication_type' }}</label>
<div class="col-sm-9">
<select ng-model="provider.providable_type"
ng-change="updateProvidable()"
class="form-control"
name="auth_provider[providable_type]"
id="provider_type"
ng-options="key as methodName(key) for (key, value) in authMethods"
ng-disabled="mode == 'edition'"
required>
</select>
<input type="hidden" name="auth_provider[type]" ng-value="provider.type" />
<span class="help-block" ng-show="providerForm['auth_provider[providable_type]'].$dirty && providerForm['auth_provider[providable_type]'].$error.required" translate>{{ 'app.shared.authentication.authentication_type_is_required' }}</span>
</div>
</div>

View File

@ -1,104 +0,0 @@
<hr/>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[base_url]'].$dirty && providerForm['auth_provider[base_url]'].$invalid}">
<label for="provider_base_url" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.common_url' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.base_url"
class="form-control"
name="auth_provider[base_url]"
id="provider_base_url"
placeholder="https://sso.example.net..."
required
url>
<span class="help-block" ng-show="providerForm['auth_provider[base_url]'].$dirty && providerForm['auth_provider[base_url]'].$error.required" translate>{{ 'app.shared.oauth2.common_url_is_required' }}</span>
<span class="help-block" ng-show="providerForm['auth_provider[base_url]'].$error.url" translate>{{ 'app.shared.oauth2.provided_url_is_not_a_valid_url' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[authorization_endpoint]'].$dirty && providerForm['auth_provider[authorization_endpoint]'].$invalid}">
<label for="provider_authorization_endpoint" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.authorization_endpoint' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.authorization_endpoint"
class="form-control"
name="auth_provider[authorization_endpoint]"
id="provider_authorization_endpoint"
placeholder="/oauth2/auth..."
required
endpoint>
<span class="help-block" ng-show="providerForm['auth_provider[authorization_endpoint]'].$dirty && providerForm['auth_provider[authorization_url]'].$error.required" translate>{{ 'app.shared.oauth2.oauth2_authorization_endpoint_is_required' }}</span>
<span class="help-block" ng-show="providerForm['auth_provider[authorization_endpoint]'].$error.endpoint" translate>{{ 'app.shared.oauth2.provided_endpoint_is_not_valid' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[token_endpoint]'].$dirty && providerForm['auth_provider[token_endpoint]'].$invalid}">
<label for="provider_token_endpoint" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.token_acquisition_endpoint' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.token_endpoint"
class="form-control"
name="auth_provider[token_endpoint]"
id="provider_token_endpoint"
placeholder="/oauth2/token..."
required
endpoint>
<span class="help-block" ng-show="providerForm['auth_provider[token_endpoint]'].$dirty && providerForm['auth_provider[token_endpoint]'].$error.required" translate>{{ 'app.shared.oauth2.oauth2_token_acquisition_endpoint_is_required' }}</span>
<span class="help-block" ng-show="providerForm['auth_provider[token_endpoint]'].$error.endpoint" translate>{{ 'app.shared.oauth2.provided_endpoint_is_not_valid' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[profile_url]'].$dirty && providerForm['auth_provider[profile_url]'].$invalid}">
<label for="provider_profile_url" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.profil_edition_url' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.profile_url"
class="form-control"
name="auth_provider[profile_url]"
id="provider_profile_url"
placeholder="https://exemple.net/user..."
required
url>
<span class="help-block" ng-show="providerForm['auth_provider[profile_url]'].$dirty && providerForm['auth_provider[profile_url]'].$error.required" translate>{{ 'app.shared.oauth2.profile_edition_url_is_required' }}</span>
<span class="help-block" ng-show="providerForm['auth_provider[profile_url]'].$error.url" translate>{{ 'app.shared.oauth2.provided_url_is_not_a_valid_url' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[client_id]'].$dirty && providerForm['auth_provider[client_id]'].$invalid}">
<label for="provider_client_id" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.client_identifier' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.client_id"
class="form-control"
name="auth_provider[client_id]"
id="provider_client_id"
required>
<span class="help-block" ng-show="providerForm['auth_provider[client_id]'].$dirty && providerForm['auth_provider[client_id]'].$error.required" translate>{{ 'app.shared.oauth2.oauth2_client_identifier_is_required' }} {{ 'obtain_it_when_registering_with_your_provider' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[client_secret]'].$dirty && providerForm['auth_provider[client_secret]'].$invalid}">
<label for="provider_client_secret" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.client_secret' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.client_secret"
class="form-control"
name="auth_provider[client_secret]"
id="provider_client_secret"
required>
<span class="help-block" ng-show="providerForm['auth_provider[client_secret]'].$dirty && providerForm['auth_provider[client_secret]'].$error.required" translate>{{ 'app.shared.oauth2.oauth2_client_secret_is_required' }} {{ 'obtain_it_when_registering_with_your_provider' }}</span>
</div>
</div>
<div class="form-group" ng-class="{'has-error': providerForm['auth_provider[scopes]'].$dirty && providerForm['auth_provider[scopes]'].$invalid}">
<label for="provider_client_secret" class="col-sm-3 control-label" translate>{{ 'app.shared.oauth2.scopes' }}</label>
<div class="col-sm-9">
<input type="text"
ng-model="provider.providable_attributes.scopes"
class="form-control"
name="auth_provider[scopes]"
id="provider_scopes"
placeholder="profile,email...">
</div>
</div>
<ng-include src="'/admin/authentications/_oauth2_mapping.html'"></ng-include>

View File

@ -1,80 +0,0 @@
<h4 class="m-l m-t-xl" translate>{{ 'app.shared.oauth2.define_the_fields_mapping' }}</h4>
<button type="button" class="btn btn-success m-l m-b" ng-click="newMapping = {}"><i class="fa fa-plus"></i> {{ 'app.shared.oauth2.add_a_match' | translate }}</button>
<table class="table">
<thead>
<tr>
<th translate>{{ 'app.shared.oauth2.model' }}</th>
<th translate>{{ 'app.shared.oauth2.field' }}</th>
<th translate>{{ 'app.shared.oauth2.api_endpoint_url' }}</th>
<th translate>{{ 'app.shared.oauth2.api_type' }}</th>
<th translate>{{ 'app.shared.oauth2.api_fields' }}</th>
<th style="width: 6.4em;"></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="m in provider.providable_attributes.o_auth2_mappings_attributes" ng-if="!m._destroy">
<td class="text-c">{{m.local_model}}</td>
<td>{{m.local_field}}</td>
<td>{{m.api_endpoint}}</td>
<td>{{m.api_data_type}}</td>
<td>{{m.api_field}}</td>
<td>
<button class="btn btn-info" ng-click="defineDataMapping(m)">
<i class="fa fa-info-circle"></i>
</button>
<button class="btn btn-danger" ng-click="m._destroy = true">
<i class="fa fa-trash-o"></i>
</button>
</td>
</tr>
<tr ng-show="newMapping" ng-form="mappingForm" isolate-form>
<td ng-class="{'has-error': mappingForm['auth_mapping[local_model]'].$dirty && mappingForm['auth_mapping[local_model]'].$invalid}">
<select class="form-control text-c"
name="auth_mapping[local_model]"
ng-options="model as model for (model, fields) in mappingFields"
ng-model="newMapping.local_model"
required>
</select>
</td>
<td ng-class="{'has-error': mappingForm['auth_mapping[local_field]'].$dirty && mappingForm['auth_mapping[local_field]'].$invalid}">
<select class="form-control"
name="auth_mapping[local_field]"
ng-options="field[0] as field[0] for field in mappingFields[newMapping.local_model]"
ng-model="newMapping.local_field"
required>
</select>
</td>
<td ng-class="{'has-error': mappingForm['auth_mapping[api_endpoint]'].$dirty && mappingForm['auth_mapping[api_endpoint]'].$invalid}">
<input type="text"
class="form-control"
placeholder="/api/resource..."
ng-model="newMapping.api_endpoint"
name="auth_mapping[api_endpoint]"
required/>
</td>
<td ng-class="{'has-error': mappingForm['auth_mapping[api_data_type]'].$dirty && mappingForm['auth_mapping[api_data_type]'].$invalid}">
<select class="form-control"
ng-model="newMapping.api_data_type"
name="auth_mapping[api_data_type]"
required>
<option value="json">JSON</option>
</select>
</td>
<td ng-class="{'has-error': mappingForm['auth_mapping[api_field]'].$dirty && mappingForm['auth_mapping[api_field]'].$invalid}">
<input type="text"
class="form-control help-cursor"
placeholder="field_name"
ng-model="newMapping.api_field"
name="auth_mapping[api_field]"
title="{{ 'app.shared.oauth2.api_field_help' | translate }}"
required/>
</td>
<td>
<button type="button" class="btn btn-success" ng-disabled="mappingForm.$invalid" ng-click="provider.providable_attributes.o_auth2_mappings_attributes.push(newMapping); newMapping = null;"><i class="fa fa-check"></i></button>
<button type="button" class="btn btn-danger" ng-click="newMapping = null"><i class="fa fa-times"></i></button>
</td>
</tr>
</tbody>
</table>

View File

@ -35,14 +35,10 @@
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<ng-include src="'/admin/authentications/_form.html'"></ng-include>
<ng-include src="'/admin/authentications/_oauth2.html'" ng-if="provider.providable_type == 'OAuth2Provider'"></ng-include>
</div> <!-- ./panel-body -->
<div class="panel-footer no-padder">
<input type="button" value="{{ 'app.shared.buttons.confirm_changes' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="providerForm.$invalid" ng-click="updateProvider()"/>
<provider-form action="'update'" on-success="onSuccess" on-error="onError" provider="provider"></provider-form>
</div>
</section>
</form>

View File

@ -31,21 +31,14 @@
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<form role="form" name="providerForm" class="form-horizontal" novalidate>
<section class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<ng-include src="'/admin/authentications/_form.html'"></ng-include>
<ng-include src="'/admin/authentications/_oauth2.html'" ng-if="provider.providable_type == 'OAuth2Provider'"></ng-include>
</div> <!-- ./panel-body -->
<provider-form action="'create'" on-success="onSuccess" on-error="onError"></provider-form>
<div class="panel-footer no-padder">
<input type="button" value="{{ 'app.shared.buttons.save' | translate }}" class="r-b btn-valid btn btn-warning btn-block p-lg btn-lg text-u-c" ng-disabled="providerForm.$invalid" ng-click="registerProvider()"/>
</div>
</section>
</form>
</div>
</div>

View File

@ -13,11 +13,18 @@ class AuthProvider < ApplicationRecord
end
end
PROVIDABLE_TYPES = %w[DatabaseProvider OAuth2Provider].freeze
PROVIDABLE_TYPES = %w[DatabaseProvider OAuth2Provider OpenIdConnectProvider].freeze
belongs_to :providable, polymorphic: true, dependent: :destroy
accepts_nested_attributes_for :providable
has_many :auth_provider_mappings, dependent: :destroy
accepts_nested_attributes_for :auth_provider_mappings, allow_destroy: true
validates :providable_type, inclusion: { in: PROVIDABLE_TYPES }
validates :name, presence: true, uniqueness: true
validates_with UserUidMappedValidator, if: -> { %w[OAuth2Provider OpenIdConnectProvider].include?(providable_type) }
before_create :set_initial_state
def build_providable(params)
@ -69,13 +76,17 @@ class AuthProvider < ApplicationRecord
## Return the provider type name without the "Provider" part.
## eg. DatabaseProvider will return 'database'
def provider_type
providable.class.name[0..-9].downcase
providable_type[0..-9].downcase
end
## Return the user's profile fields that are currently managed from the SSO
## @return [Array]
def sso_fields
providable.protected_fields
fields = []
auth_provider_mappings.each do |mapping|
fields.push(mapping.local_model + '.' + mapping.local_field)
end
fields
end
## Return the link the user have to follow to edit his profile on the SSO

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
# AuthProviderMapping defines the relationship between a database field (saving user's data)
# and an external API, that is authorized through an external SSO (like oAuth 2.0).
class AuthProviderMapping < ApplicationRecord
belongs_to :auth_provider
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Read and write the amount attribute, after converting to/from cents.
module AmountConcern
extend ActiveSupport::Concern

View File

@ -0,0 +1,162 @@
# frozen_string_literal: true
# Add single sign on functionalities to the user model
module SingleSignOnConcern
extend ActiveSupport::Concern
require 'sso_logger'
included do
# enable OmniAuth authentication only if needed
devise :omniauthable, omniauth_providers: [AuthProvider.active.strategy_name.to_sym] unless
AuthProvider.active.providable_type == DatabaseProvider.name
## Retrieve the requested data in the User and user's Profile tables
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
def get_data_from_sso_mapping(sso_mapping)
parsed = /^(user|profile)\.(.+)$/.match(sso_mapping)
if parsed[1] == 'user'
self[parsed[2].to_sym]
elsif parsed[1] == 'profile'
case sso_mapping
when 'profile.avatar'
profile.user_avatar.remote_attachment_url
when 'profile.address'
invoicing_profile.address.address
when 'profile.organization_name'
invoicing_profile.organization.name
when 'profile.organization_address'
invoicing_profile.organization.address.address
when 'profile.gender'
statistic_profile.gender
when 'profile.birthday'
statistic_profile.birthday
else
profile[parsed[2].to_sym]
end
end
end
## Set some data on the current user, according to the sso_key given
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
## @param data {*} the data to put in the given key. Eg. 'user@example.com'
def set_data_from_sso_mapping(sso_mapping, data)
if sso_mapping.to_s.start_with? 'user.'
self[sso_mapping[5..-1].to_sym] = data unless data.nil?
elsif sso_mapping.to_s.start_with? 'profile.'
case sso_mapping.to_s
when 'profile.avatar'
profile.user_avatar ||= UserAvatar.new
profile.user_avatar.remote_attachment_url = data
when 'profile.address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.address ||= Address.new
invoicing_profile.address.address = data
when 'profile.organization_name'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.name = data
when 'profile.organization_address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.address ||= Address.new
invoicing_profile.organization.address.address = data
when 'profile.gender'
statistic_profile ||= StatisticProfile.new
statistic_profile.gender = data
when 'profile.birthday'
statistic_profile ||= StatisticProfile.new
statistic_profile.birthday = data
else
profile[sso_mapping[8..-1].to_sym] = data unless data.nil?
end
end
end
## used to allow the migration of existing users between authentication providers
def generate_auth_migration_token
update_attributes(auth_token: Devise.friendly_token)
end
## link the current user to the given provider (omniauth attributes hash)
## and remove the auth_token to mark his account as "migrated"
def link_with_omniauth_provider(auth)
active_provider = AuthProvider.active
raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider
if User.where(provider: auth.provider, uid: auth.uid).size.positive?
raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user"
end
update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil)
end
## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity
## @param sso_user {User} the provided user will be DELETED after the merge was successful
def merge_from_sso(sso_user)
logger = SsoLogger.new
logger.debug "[User::merge_from_sso] initiated with parameter #{sso_user}"
# update the attributes to link the account to the sso account
self.provider = sso_user.provider
self.uid = sso_user.uid
# remove the token
self.auth_token = nil
self.merged_at = DateTime.current
# check that the email duplication was resolved
if sso_user.email.end_with? '-duplicate'
email_addr = sso_user.email.match(/^<([^>]+)>.{20}-duplicate$/)[1]
logger.error 'duplicate email was not resolved'
raise(DuplicateIndexError, email_addr) unless email_addr == email
end
# update the user's profile to set the data managed by the SSO
auth_provider = AuthProvider.from_strategy_name(sso_user.provider)
logger.debug "found auth_provider=#{auth_provider.name}"
auth_provider.sso_fields.each do |field|
value = sso_user.get_data_from_sso_mapping(field)
logger.debug "mapping sso field #{field} with value=#{value}"
# we do not merge the email field if its end with the special value '-duplicate' as this means
# that the user is currently merging with the account that have the same email than the sso
set_data_from_sso_mapping(field, value) unless field == 'user.email' && value.end_with?('-duplicate')
end
# run the account transfer in an SQL transaction to ensure data integrity
begin
User.transaction do
# remove the temporary account
logger.debug 'removing the temporary user'
sso_user.destroy
# finally, save the new details
logger.debug 'saving the updated user'
save!
end
rescue ActiveRecord::RecordInvalid => e
logger.error "error while merging user #{sso_user.id} into #{id}: #{e.message}"
raise e
end
end
end
class_methods do
def from_omniauth(auth)
logger = SsoLogger.new
logger.debug "[User::from_omniauth] initiated with parameter #{auth}"
active_provider = AuthProvider.active
raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider
where(provider: auth.provider, uid: auth.uid).first_or_create.tap do |user|
# execute this regardless of whether record exists or not (-> User#tap)
# this will init or update the user thanks to the information retrieved from the SSO
logger.debug user.id.nil? ? 'no user found, creating a new one' : "found user id=#{user.id}"
user.profile ||= Profile.new
auth.info.mapping.each do |key, value|
logger.debug "mapping info #{key} with value=#{value}"
user.set_data_from_sso_mapping(key, value)
end
logger.debug 'generating a new password'
user.password = Devise.friendly_token[0, 20]
end
end
end
end

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
# Defines the standard statistics data model.
module StatConcern
extend ActiveSupport::Concern

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Defines the reservation statistics data model
module StatReservationConcern
extend ActiveSupport::Concern

View File

@ -5,9 +5,7 @@
class DatabaseProvider < ApplicationRecord
has_one :auth_provider, as: :providable, dependent: :destroy
def protected_fields
[]
end
validates_with DatabaseProviderValidator
def profile_url
'/#!/dashboard/profile'

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
# OAuth2Mapping defines a database field, saving user's data, that is mapped to an external API, that is authorized
# through an external SSO of type oAuth 2
class OAuth2Mapping < ApplicationRecord
belongs_to :o_auth2_provider
end

View File

@ -4,18 +4,9 @@
# the oAuth 2.0 protocol.
class OAuth2Provider < ApplicationRecord
has_one :auth_provider, as: :providable
has_many :o_auth2_mappings, dependent: :destroy
accepts_nested_attributes_for :o_auth2_mappings, allow_destroy: true
def domain
URI(base_url).scheme + '://' + URI(base_url).host
end
def protected_fields
fields = []
o_auth2_mappings.each do |mapping|
fields.push(mapping.local_model + '.' + mapping.local_field)
end
fields
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
# OpenIdConnectProvider is a special type of AuthProvider which provides authentication through an external SSO server using
# the OpenID Connect protocol.
class OpenIdConnectProvider < ApplicationRecord
has_one :auth_provider, as: :providable
validates :issuer, presence: true
validates :client__identifier, presence: true
validates :client__secret, presence: true
validates :client__host, presence: true
validates :client__scheme, inclusion: { in: %w[http https] }
validates :client__port, numericality: { only_integer: true, greater_than: 0, less_than: 65_535 }
validates :response_type, inclusion: { in: %w[code id_token], allow_nil: true }
validates :response_mode, inclusion: { in: %w[query fragment form_post web_message], allow_nil: true }
validates :display, inclusion: { in: %w[page popup touch wap], allow_nil: true }
validates :prompt, inclusion: { in: %w[none login consent select_account], allow_nil: true }
validates :client_auth_method, inclusion: { in: %w[basic jwks] }
before_validation :set_post_logout_redirect_uri
before_validation :set_client_scheme_host_port
before_validation :set_redirect_uri
before_validation :set_display
before_validation :set_response_type
def config
OpenIdConnectProvider.columns.map(&:name).filter { |n| !n.start_with?('client__') && n != 'profile_url' }.map do |n|
[n, send(n)]
end.push(['client_options', client_config]).to_h
end
def client_config
OpenIdConnectProvider.columns.map(&:name).filter { |n| n.start_with?('client__') }.map do |n|
[n.sub('client__', ''), send(n)]
end.to_h
end
private
def set_post_logout_redirect_uri
self.post_logout_redirect_uri = "#{ENV.fetch('DEFAULT_PROTOCOL')}://#{ENV.fetch('DEFAULT_HOST')}/sessions/sign_out"
end
def set_redirect_uri
self.client__redirect_uri = "#{ENV.fetch('DEFAULT_PROTOCOL')}://#{ENV.fetch('DEFAULT_HOST')}/users/auth/#{auth_provider.strategy_name}/callback"
end
def set_display
self.display = 'page'
end
def set_response_mode
self.response_mode = 'query'
end
def set_response_type
self.response_type = 'code'
end
def set_client_scheme_host_port
require 'uri'
URI.parse(issuer).tap do |uri|
self.client__scheme = uri.scheme
self.client__host = uri.host
self.client__port = uri.port
end
end
end

View File

@ -3,20 +3,16 @@
# User is a physical or moral person with its authentication parameters
# It is linked to the Profile model with hold information about this person (like address, name, etc.)
class User < ApplicationRecord
require 'sso_logger'
include NotifyWith::NotificationReceiver
include NotifyWith::NotificationAttachedObject
include SingleSignOnConcern
# Include default devise modules. Others available are:
# :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable,
:confirmable
rolify
# enable OmniAuth authentication only if needed
devise :omniauthable, omniauth_providers: [AuthProvider.active.strategy_name.to_sym] unless
AuthProvider.active.providable_type == DatabaseProvider.name
extend FriendlyId
friendly_id :username, use: :slugged
@ -204,26 +200,6 @@ class User < ApplicationRecord
super && is_active?
end
def self.from_omniauth(auth)
logger = SsoLogger.new
logger.debug "[User::from_omniauth] initiated with parameter #{auth}"
active_provider = AuthProvider.active
raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider
where(provider: auth.provider, uid: auth.uid).first_or_create.tap do |user|
# execute this regardless of whether record exists or not (-> User#tap)
# this will init or update the user thanks to the information retrieved from the SSO
logger.debug user.id.nil? ? 'no user found, creating a new one' : "found user id=#{user.id}"
user.profile ||= Profile.new
auth.info.mapping.each do |key, value|
logger.debug "mapping info #{key} with value=#{value}"
user.set_data_from_sso_mapping(key, value)
end
logger.debug "generating a new password"
user.password = Devise.friendly_token[0, 20]
end
end
def need_completion?
statistic_profile.gender.nil? || profile.first_name.blank? || profile.last_name.blank? || username.blank? ||
email.blank? || encrypted_password.blank? || group_id.nil? || statistic_profile.birthday.blank? ||
@ -231,133 +207,6 @@ class User < ApplicationRecord
(Setting.get('address_required') && invoicing_profile.address&.address&.blank?)
end
## Retrieve the requested data in the User and user's Profile tables
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
def get_data_from_sso_mapping(sso_mapping)
parsed = /^(user|profile)\.(.+)$/.match(sso_mapping)
if parsed[1] == 'user'
self[parsed[2].to_sym]
elsif parsed[1] == 'profile'
case sso_mapping
when 'profile.avatar'
profile.user_avatar.remote_attachment_url
when 'profile.address'
invoicing_profile.address.address
when 'profile.organization_name'
invoicing_profile.organization.name
when 'profile.organization_address'
invoicing_profile.organization.address.address
when 'profile.gender'
statistic_profile.gender
when 'profile.birthday'
statistic_profile.birthday
else
profile[parsed[2].to_sym]
end
end
end
## Set some data on the current user, according to the sso_key given
## @param sso_mapping {String} must be of form 'user._field_' or 'profile._field_'. Eg. 'user.email'
## @param data {*} the data to put in the given key. Eg. 'user@example.com'
def set_data_from_sso_mapping(sso_mapping, data)
if sso_mapping.to_s.start_with? 'user.'
self[sso_mapping[5..-1].to_sym] = data unless data.nil?
elsif sso_mapping.to_s.start_with? 'profile.'
case sso_mapping.to_s
when 'profile.avatar'
profile.user_avatar ||= UserAvatar.new
profile.user_avatar.remote_attachment_url = data
when 'profile.address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.address ||= Address.new
invoicing_profile.address.address = data
when 'profile.organization_name'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.name = data
when 'profile.organization_address'
invoicing_profile ||= InvoicingProfile.new
invoicing_profile.organization ||= Organization.new
invoicing_profile.organization.address ||= Address.new
invoicing_profile.organization.address.address = data
when 'profile.gender'
statistic_profile ||= StatisticProfile.new
statistic_profile.gender = data
when 'profile.birthday'
statistic_profile ||= StatisticProfile.new
statistic_profile.birthday = data
else
profile[sso_mapping[8..-1].to_sym] = data unless data.nil?
end
end
end
## used to allow the migration of existing users between authentication providers
def generate_auth_migration_token
update_attributes(auth_token: Devise.friendly_token)
end
## link the current user to the given provider (omniauth attributes hash)
## and remove the auth_token to mark his account as "migrated"
def link_with_omniauth_provider(auth)
active_provider = AuthProvider.active
raise SecurityError, 'The identity provider does not match the activated one' if active_provider.strategy_name != auth.provider
if User.where(provider: auth.provider, uid: auth.uid).size.positive?
raise DuplicateIndexError, "This #{active_provider.name} account is already linked to an existing user"
end
update_attributes(provider: auth.provider, uid: auth.uid, auth_token: nil)
end
## Merge the provided User's SSO details into the current user and drop the provided user to ensure the unity
## @param sso_user {User} the provided user will be DELETED after the merge was successful
def merge_from_sso(sso_user)
logger = SsoLogger.new
logger.debug "[User::merge_from_sso] initiated with parameter #{sso_user}"
# update the attributes to link the account to the sso account
self.provider = sso_user.provider
self.uid = sso_user.uid
# remove the token
self.auth_token = nil
self.merged_at = DateTime.current
# check that the email duplication was resolved
if sso_user.email.end_with? '-duplicate'
email_addr = sso_user.email.match(/^<([^>]+)>.{20}-duplicate$/)[1]
logger.error 'duplicate email was not resolved'
raise(DuplicateIndexError, email_addr) unless email_addr == email
end
# update the user's profile to set the data managed by the SSO
auth_provider = AuthProvider.from_strategy_name(sso_user.provider)
logger.debug "found auth_provider=#{auth_provider.name}"
auth_provider.sso_fields.each do |field|
value = sso_user.get_data_from_sso_mapping(field)
logger.debug "mapping sso field #{field} with value=#{value}"
# we do not merge the email field if its end with the special value '-duplicate' as this means
# that the user is currently merging with the account that have the same email than the sso
set_data_from_sso_mapping(field, value) unless field == 'user.email' && value.end_with?('-duplicate')
end
# run the account transfer in an SQL transaction to ensure data integrity
begin
User.transaction do
# remove the temporary account
logger.debug 'removing the temporary user'
sso_user.destroy
# finally, save the new details
logger.debug 'saving the updated user'
save!
end
rescue ActiveRecord::RecordInvalid => e
logger.error "error while merging user #{sso_user.id} into #{id}: #{e.message}"
raise e
end
end
def self.mapping
# we protect some fields as they are designed to be managed by the system and must not be updated externally
blacklist = %w[id encrypted_password reset_password_token reset_password_sent_at remember_created_at

View File

@ -1,12 +1,15 @@
# frozen_string_literal: true
# Check the access policies for API::AuthProvidersController
class AuthProviderPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.includes(:providable)
scope.includes(:providable, :auth_provider_mappings)
end
end
%w(index? show? create? update? destroy? mapping_fields?).each do |action|
%w[index? show? create? update? destroy? mapping_fields? strategy_name?].each do |action|
define_method action do
user.admin?
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
# Validates there's only one database provider
class DatabaseProviderValidator < ActiveModel::Validator
def validate(record)
return if DatabaseProvider.count.zero?
record.errors[:id] << I18n.t('authentication_providers.local_database_provider_already_exists')
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Validates the presence of the User.uid mapping
class UserUidMappedValidator < ActiveModel::Validator
def validate(record)
return unless %w[OAuth2Provider OpenIdConnectProvider].include?(record.providable_type)
return if record.auth_provider_mappings.any? do |mapping|
mapping.local_model == 'user' && mapping.local_field == 'uid'
end
record.errors.add(:uid, I18n.t('authentication_providers.matching_between_User_uid_and_API_required'))
end
end

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
json.extract! auth_provider, :id, :name, :status, :providable_type, :strategy_name
json.auth_provider_mappings_attributes auth_provider.auth_provider_mappings do |m|
json.extract! m, :id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type, :transformation
end

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
json.partial! 'api/auth_providers/auth_provider', auth_provider: @provider
# OAuth 2.0
@ -5,8 +7,13 @@ json.partial! 'api/auth_providers/auth_provider', auth_provider: @provider
if @provider.providable_type == OAuth2Provider.name
json.providable_attributes do
json.extract! @provider.providable, :id, :base_url, :token_endpoint, :authorization_endpoint, :profile_url, :client_id, :client_secret, :scopes
json.o_auth2_mappings_attributes @provider.providable.o_auth2_mappings do |m|
json.extract! m, :id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type, :transformation
end
end
if @provider.providable_type == OpenIdConnectProvider.name
json.providable_attributes do
json.extract! @provider.providable, :id, :issuer, :discovery, :client_auth_method, :scope,
:prompt, :send_scope_to_token_endpoint, :client__identifier, :client__secret, :client__authorization_endpoint,
:client__token_endpoint, :client__userinfo_endpoint, :client__jwks_uri, :client__end_session_endpoint, :profile_url
end
end

View File

@ -41,7 +41,7 @@
Fablab.defaultHost = "<%= Rails.application.secrets.default_host %>";
Fablab.trackingId = "<%= Setting.get('tracking_id') %>";
Fablab.adminSysId = parseInt("<%= User.adminsys&.id %>", 10);
Fablab.baseHostUrl = "<%= Rails.application.secrets.default_host %>";
Fablab.activeProviderType = "<%= AuthProvider.active&.providable_type %>";
// i18n stuff
Fablab.locale = "<%= Rails.application.secrets.app_locale %>";

View File

@ -228,10 +228,16 @@ Devise.setup do |config|
# up on your models and hooks.
# config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo'
require_relative '../../lib/omni_auth/omni_auth'
active_provider = AuthProvider.active
if active_provider.providable_type == OAuth2Provider.name
config.omniauth OmniAuth::Strategies::SsoOauth2Provider.name.to_sym, active_provider.providable.client_id, active_provider.providable.client_secret
require_relative '../../lib/omni_auth/oauth2'
config.omniauth OmniAuth::Strategies::SsoOauth2Provider.name.to_sym,
active_provider.providable.client_id,
active_provider.providable.client_secret
elsif active_provider.providable_type == OpenIdConnectProvider.name
require_relative '../../lib/omni_auth/openid_connect'
config.omniauth OmniAuth::Strategies::SsoOpenidConnectProvider.name.to_sym,
active_provider.providable.config
end
# ==> Warden configuration

View File

@ -878,6 +878,7 @@ en:
an_error_occurred_unable_to_delete_the_specified_provider: "An error occurred: unable to delete the specified provider."
local_database: "Local database"
o_auth2: "OAuth 2.0"
openid_connect: "OpenID Connect"
group_form:
add_a_group: "Add a group"
group_name: "Group name"
@ -1050,26 +1051,110 @@ en:
birth_date: "Date of birth"
address: "Address"
phone_number: "Phone number"
#add a new authentication provider (SSO)
#authentication providers (SSO) components
authentication:
boolean_mapping_form:
mappings: "Mappings"
true_value: "True value"
false_value: "False value"
date_mapping_form:
input_format: "Input format"
date_format: "Date format"
integer_mapping_form:
mappings: "Mappings"
mapping_from: "From"
mapping_to: "To"
string_mapping_form:
mappings: "Mappings"
mapping_from: "From"
mapping_to: "To"
data_mapping_form:
define_the_fields_mapping: "Define the fields mapping"
add_a_match: "Add a match"
model: "Model"
field: "Field"
data_mapping: "Data mapping"
oauth2_data_mapping_form:
api_endpoint_url: "API endpoint or URL"
api_type: "API type"
api_field: "API field"
api_field_help_html: '<a href="https://jsonpath.com/" target="_blank">JsonPath</a> syntax is supported.<br> If many fields are selected, the first one will be used.<br> Example: $.data[*].name'
openid_connect_data_mapping_form:
api_field: "Userinfo claim"
api_field_help_html: 'Set the field providing the corresponding data through <a href="https://openid.net/specs/openid-connect-core-1_0.html#Claims" target="_blank">the userinfo endpoint</a>.<br> <a href="https://jsonpath.com/" target="_blank">JsonPath</a> syntax is supported. If many fields are selected, the first one will be used.<br> <b>Example</b>: $.data[*].name'
openid_standard_configuration: "Use the OpenID standard configuration"
type_mapping_modal:
data_mapping: "Data mapping"
TYPE_expected: "{TYPE} expected"
types:
integer: "integer"
string: "string"
text: "text"
date: "date"
boolean: "boolean"
oauth2_form:
authorization_callback_url: "Authorization callback URL"
common_url: "Server root URL"
authorization_endpoint: "Authorization endpoint"
token_acquisition_endpoint: "Token acquisition endpoint"
profile_edition_url: "Profil edition URL"
profile_edition_url_help: "The URL of the page where the user can edit his profile."
client_identifier: "Client identifier"
client_secret: "Client secret"
scopes: "Scopes"
openid_connect_form:
issuer: "Issuer"
issuer_help: "Root url for the authorization server."
discovery: "Discovery"
discovery_help: "Should OpenID discovery be used. This is recommended if the IDP provides a discovery endpoint."
discovery_unavailable: "Discovery is unavailable for the configured issuer."
discovery_enabled: "Enable discovery"
discovery_disabled: "Disable discovery"
client_auth_method: "Client authentication method"
client_auth_method_help: "Which authentication method to use to authenticate Fab-manager with the authorization server."
client_auth_method_basic: "Basic"
client_auth_method_jwks: "JWKS"
scope: "Scope"
scope_help_html: "Which OpenID scopes to include (openid is always required). <br> If <b>Discovery</b> is enabled, the available scopes will be automatically proposed."
prompt: "Prompt"
prompt_help_html: "Which OpenID pages the user will be shown. <br> <b>None</b> - no authentication or consent user interface pages are shown. <br> <b>Login</b> - the authorization server prompt the user for reauthentication. <br> <b>Consent</b> - the authorization server prompt the user for consent before returning information to Fab-manager. <br> <b>Select account</b> - the authorization server prompt the user to select a user account."
prompt_none: "None"
prompt_login: "Login"
prompt_consent: "Consent"
prompt_select_account: "Select account"
send_scope_to_token_endpoint: "Send scope to token endpoint?"
send_scope_to_token_endpoint_help: "Should the scope parameter be sent to the authorization token endpoint?"
send_scope_to_token_endpoint_false: "No"
send_scope_to_token_endpoint_true: "Yes"
extra_authorize_params: "Extra authorize params"
extra_authorize_params_help_html: "A list of extra fixed parameters that will be merged to the authorization request.<br>The list is expected to be in a JSON-like format.<br> <b>Eg.</b> {tenant: common, max_age: 3600}"
profile_edition_url: "Profil edition URL"
profile_edition_url_help: "The URL of the page where the user can edit his profile."
client_options: "Client options"
client__identifier: "Identifier"
client__secret: "Secret"
client__authorization_endpoint: "Authorization endpoint"
client__token_endpoint: "Token endpoint"
client__userinfo_endpoint: "Userinfo endpoint"
client__jwks_uri: "JWKS URI"
client__end_session_endpoint: "End session endpoint"
client__end_session_endpoint_help: "The url to call to log the user out at the authorization server."
provider_form:
name: "Name"
authentication_type: "Authentication type"
save: "Save"
create_success: "Authentication provider created"
update_success: "Authentication provider updated"
methods:
local_database: "Local database"
oauth2: "OAuth 2.0"
openid_connect: "OpenID Connect"
#create a new authentication provider (SSO)
authentication_new:
local_database: "Local Database"
o_auth2: "OAuth 2.0"
add_a_new_authentication_provider: "Add a new authentication provider"
a_local_database_provider_already_exists_unable_to_create_another: "A \"Local Database\" provider already exists. Unable to create another."
local_provider_successfully_saved: "Local provider successfully saved."
it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider: "It is required to set the matching between User.uid and the API to add this provider."
security_issue_detected: "Security issue detected"
beware_the_oauth2_authenticatoin_provider_you_are_about_to_add_isnt_using_HTTPS: "Beware: the OAuth 2 provider you are about to add isn't using HTTPS."
this_is_a_serious_security_issue_on_internet_and_should_never_be_used_except_for_testing_purposes: "This is a serious security issue on internet and should never be used except for testing purposes."
do_you_really_want_to_continue: "Do you really want to continue?"
unsecured_oauth2_provider_successfully_added: "Unsecured OAuth 2.0 provider successfully added."
oauth2_provider_successfully_added: "OAuth 2.0 provider successfully added."
#edit an authentication provider (SSO)
authentication_edit:
provider: "Provider:"
it_is_required_to_set_the_matching_between_User.uid_and_the_API_to_add_this_provider: "It is required to set the matching between User.uid and the API to add this provider."
provider_successfully_updated: "Provider successfully updated."
an_error_occurred_unable_to_update_the_provider: "An error occurred: unable to update the provider."
#statistics tables
statistics:
statistics: "Statistics"

View File

@ -247,44 +247,6 @@ en:
group_is_required: "Group is required."
trainings: "Trainings"
tags: "Tags"
#partial form to edit/create an authentication provider (SSO)
authentication:
name: "Name"
provider_name_is_required: "Provider name is required."
authentication_type: "Authentication type"
local_database: "Local database"
o_auth2: "OAuth 2.0"
authentication_type_is_required: "Authentication type is required."
data_mapping: "Data mapping"
expected_data_type: "Expected data type"
input_format: "Input format"
mappings: "Mappings"
#edition/creation form of an OAuth2 authentication provider
oauth2:
common_url: "Server root URL"
common_url_is_required: "Common URL is required."
provided_url_is_not_a_valid_url: "Provided URL is not a valid URL."
authorization_endpoint: "Authorization endpoint"
oauth2_authorization_endpoint_is_required: "OAuth 2.0 authorization endpoint is required."
provided_endpoint_is_not_valid: "Provided endpoint is not valid."
token_acquisition_endpoint: "Token acquisition endpoint"
oauth2_token_acquisition_endpoint_is_required: "OAuth 2.0 token acquisition endpoint is required."
profil_edition_url: "Profil edition URL"
profile_edition_url_is_required: "Profile edition URL is required."
client_identifier: "Client identifier"
oauth2_client_identifier_is_required: "OAuth 2.0 client identifier is required."
obtain_it_when_registering_with_your_provider: "Obtain it when registering with your provider."
client_secret: "Client secret"
oauth2_client_secret_is_required: "OAuth 2.0 client secret is required."
scopes: "Scopes"
define_the_fields_mapping: "Define the fields mapping"
add_a_match: "Add a match"
model: "Model"
field: "Fiels"
api_endpoint_url: "API endpoint URL"
api_type: "API type"
api_fields: "API fields"
api_field_help: "JsonPath syntax is supported.\n If many fields are selected, the first one will be used.\n Example: $.data[*].name"
#machine/training slot modification modal
confirm_modify_slot_modal:
change_the_slot: "Change the slot"

View File

@ -275,7 +275,7 @@ fr:
api_endpoint_url: "Point d'accès/URL de l'API"
api_type: "Type d'API"
api_fields: "Champ de l'API"
api_field_help: "La syntaxe JsonPath est prise en charge.\n Si plusieurs champs sont sélectionnés, le premier sera utilisé.\n Exemple : $.data[*].name"
api_field_help_html: '<p>La syntaxe <a href="https://jsonpath.com/" target="_blank">JsonPath</a> est prise en charge.\n Si plusieurs champs sont sélectionnés, le premier sera utilisé.\n Exemple : $.data[*].name</p>'
#machine/training slot modification modal
confirm_modify_slot_modal:
change_the_slot: "Modifier le créneau"

View File

@ -61,6 +61,10 @@ en:
your_authentication_code_is_not_valid: "Your authentication code is not valid."
current_authentication_method_no_code: "The current authentication method does not require any migration code"
requested_account_does_not_exists: "The requested account does not exist"
#SSO external authentication
authentication_providers:
local_database_provider_already_exists: 'A "Local Database" provider already exists. Unable to create another.'
matching_between_User_uid_and_API_required: "It is required to set the matching between User.uid and the API to add this provider."
#PDF invoices generation
invoices:
refund_invoice_reference: "Refund invoice reference: %{REF}"

View File

@ -17,6 +17,10 @@ Rails.application.routes.draw do
get '/sso-redirect', to: 'application#sso_redirect', as: :sso_redirect
end
devise_scope :user do
get '/sessions/sign_out', to: 'devise/sessions#destroy'
end
## The priority is based upon order of creation: first created -> highest priority.
## See how all your routes lay out with "rake routes".
@ -151,6 +155,7 @@ Rails.application.routes.draw do
get 'mapping_fields', on: :collection
get 'active', action: 'active', on: :collection
post 'send_code', action: 'send_code', on: :collection
get 'strategy_name', action: 'strategy_name', on: :collection
end
resources :abuses, only: %i[index create destroy]
resources :open_api_clients, only: %i[index create update destroy] do

View File

@ -33,7 +33,7 @@ const customConfig = {
}),
isDevelopment && new (require('@pmmmwh/react-refresh-webpack-plugin'))(),
isDevelopment && new (require('eslint-webpack-plugin'))({
extensions: ['js', 'ts', 'tsx'],
extensions: ['js', 'ts', 'tsx']
})
].filter(Boolean),
module: {

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
# This migration allow configuration of OpenID Connect providers
class CreateOpenIdConnectProviders < ActiveRecord::Migration[5.2]
def change
create_table :open_id_connect_providers do |t|
t.string :issuer
t.boolean :discovery
t.string :client_auth_method
t.string :scope
t.string :response_type
t.string :response_type
t.string :response_mode
t.string :display
t.string :prompt
t.boolean :send_scope_to_token_endpoint
t.string :post_logout_redirect_uri
t.string :uid_field
t.string :client__identifier
t.string :client__secret
t.string :client__redirect_uri
t.string :client__scheme
t.string :client__host
t.string :client__port
t.string :client__authorization_endpoint
t.string :client__token_endpoint
t.string :client__userinfo_endpoint
t.string :client__jwks_uri
t.string :client__end_session_endpoint
t.string :profile_url
t.timestamps
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
# This migration renames the OAuth2Mappings table to AuthProviderMappings because the
# field mapping is common to all kinds of single-sign-on providers.
class RenameOAuth2MappingsToAuthProviderMappings < ActiveRecord::Migration[5.2]
def change
rename_table :o_auth2_mappings, :auth_provider_mappings
add_reference :auth_provider_mappings, :auth_provider, index: true, foreign_key: true
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
# Previously, the AuthProviderMapping was saving an o_auth2_provider_id.
# This migration migrates that data to bind the mappings directly to an AuthProvider as this table is now protocol-generic.
class MigrateOAuth2ProviderIdFromAuthProviderMappings < ActiveRecord::Migration[5.2]
def up
execute <<~SQL
UPDATE auth_provider_mappings
SET auth_provider_id = auth_providers.id
FROM o_auth2_providers
INNER JOIN auth_providers ON auth_providers.providable_id = o_auth2_providers.id
AND auth_providers.providable_type = 'OAuth2Provider'
WHERE auth_provider_mappings.o_auth2_provider_id = o_auth2_providers.id
SQL
remove_reference :auth_provider_mappings, :o_auth2_provider, index: true, foreign_key: true
end
def down
add_reference :auth_provider_mappings, :o_auth2_provider, index: true, foreign_key: true
execute <<~SQL
UPDATE auth_provider_mappings
SET o_auth2_provider_id = o_auth2_providers.id
FROM o_auth2_providers
INNER JOIN auth_providers ON auth_providers.providable_id = o_auth2_providers.id
AND auth_providers.providable_type = 'OAuth2Provider'
WHERE auth_provider_mappings.auth_provider_id = auth_providers.id
SQL
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_03_22_135836) do
ActiveRecord::Schema.define(version: 2022_03_28_145017) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
enable_extension "unaccent"
create_table "abuses", id: :serial, force: :cascade do |t|
t.string "signaled_type"
t.integer "signaled_id"
t.string "signaled_type"
t.string "first_name"
t.string "last_name"
t.string "email"
@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
t.string "locality"
t.string "country"
t.string "postal_code"
t.string "placeable_type"
t.integer "placeable_id"
t.string "placeable_type"
t.datetime "created_at"
t.datetime "updated_at"
end
@ -64,14 +64,27 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
end
create_table "assets", id: :serial, force: :cascade do |t|
t.string "viewable_type"
t.integer "viewable_id"
t.string "viewable_type"
t.string "attachment"
t.string "type"
t.datetime "created_at"
t.datetime "updated_at"
end
create_table "auth_provider_mappings", id: :serial, force: :cascade do |t|
t.string "local_field"
t.string "api_field"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "local_model"
t.string "api_endpoint"
t.string "api_data_type"
t.jsonb "transformation"
t.bigint "auth_provider_id"
t.index ["auth_provider_id"], name: "index_auth_provider_mappings_on_auth_provider_id"
end
create_table "auth_providers", id: :serial, force: :cascade do |t|
t.string "name"
t.string "status"
@ -133,8 +146,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
end
create_table "credits", id: :serial, force: :cascade do |t|
t.string "creditable_type"
t.integer "creditable_id"
t.string "creditable_type"
t.integer "plan_id"
t.integer "hours"
t.datetime "created_at"
@ -356,32 +369,19 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
create_table "notifications", id: :serial, force: :cascade do |t|
t.integer "receiver_id"
t.string "attached_object_type"
t.integer "attached_object_id"
t.string "attached_object_type"
t.integer "notification_type_id"
t.boolean "is_read", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "receiver_type"
t.boolean "is_send", default: false
t.jsonb "meta_data", default: "{}"
t.jsonb "meta_data", default: {}
t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id"
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
end
create_table "o_auth2_mappings", id: :serial, force: :cascade do |t|
t.integer "o_auth2_provider_id"
t.string "local_field"
t.string "api_field"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "local_model"
t.string "api_endpoint"
t.string "api_data_type"
t.jsonb "transformation"
t.index ["o_auth2_provider_id"], name: "index_o_auth2_mappings_on_o_auth2_provider_id"
end
create_table "o_auth2_providers", id: :serial, force: :cascade do |t|
t.string "base_url"
t.string "token_endpoint"
@ -411,6 +411,34 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
t.datetime "updated_at", null: false
end
create_table "open_id_connect_providers", force: :cascade do |t|
t.string "issuer"
t.boolean "discovery"
t.string "client_auth_method"
t.string "scope"
t.string "response_type"
t.string "response_mode"
t.string "display"
t.string "prompt"
t.boolean "send_scope_to_token_endpoint"
t.string "post_logout_redirect_uri"
t.string "uid_field"
t.string "client__identifier"
t.string "client__secret"
t.string "client__redirect_uri"
t.string "client__scheme"
t.string "client__host"
t.string "client__port"
t.string "client__authorization_endpoint"
t.string "client__token_endpoint"
t.string "client__userinfo_endpoint"
t.string "client__jwks_uri"
t.string "client__end_session_endpoint"
t.string "profile_url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "organizations", id: :serial, force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
@ -542,8 +570,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
create_table "prices", id: :serial, force: :cascade do |t|
t.integer "group_id"
t.integer "plan_id"
t.string "priceable_type"
t.integer "priceable_id"
t.string "priceable_type"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -653,8 +681,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
t.text "message"
t.datetime "created_at"
t.datetime "updated_at"
t.string "reservable_type"
t.integer "reservable_id"
t.string "reservable_type"
t.integer "nb_reserve_places"
t.integer "statistic_profile_id"
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id"
@ -663,8 +691,8 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
create_table "roles", id: :serial, force: :cascade do |t|
t.string "name"
t.string "resource_type"
t.integer "resource_id"
t.string "resource_type"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
@ -983,6 +1011,7 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
end
add_foreign_key "accounting_periods", "users", column: "closed_by"
add_foreign_key "auth_provider_mappings", "auth_providers"
add_foreign_key "availability_tags", "availabilities"
add_foreign_key "availability_tags", "tags"
add_foreign_key "event_price_categories", "events"
@ -1000,7 +1029,6 @@ ActiveRecord::Schema.define(version: 2022_03_22_135836) do
add_foreign_key "invoices", "statistic_profiles"
add_foreign_key "invoices", "wallet_transactions"
add_foreign_key "invoicing_profiles", "users"
add_foreign_key "o_auth2_mappings", "o_auth2_providers"
add_foreign_key "organizations", "invoicing_profiles"
add_foreign_key "payment_gateway_objects", "payment_gateway_objects"
add_foreign_key "payment_schedule_items", "invoices"

View File

@ -27,6 +27,7 @@ The following guides are designed for the people that perform software maintenan
- [Advanced PostgreSQL usage](postgresql_readme.md)
- [Connecting an SSO using oAuth 2.0](sso_with_github.md)
- [Connecting an SSO using OpenID Connect](sso_open_id_connect.md)
- [Upgrade from Fab-manager v1.0](upgrade_v1.md)

View File

@ -50,7 +50,7 @@ This might work on other linux systems, and CPU architectures but this is untest
`curl` and `bash` are needed to retrieve and run the automated deployment scripts.
Then the various scripts will check for their own dependencies.
Moreover, the main software dependencies to run fab-manager are [Docker](https://docs.docker.com/engine/installation/linux/docker-ce/debian/) and [Docker Compose](https://docs.docker.com/compose/install/)
Moreover, the main software dependencies to run fab-manager are [Docker](https://docs.docker.com/engine/installation/linux/docker-ce/debian/) v20.0 or above and [Docker Compose](https://docs.docker.com/compose/install/)
They can be easily installed using the [`prepare-vps.sleede.com` script below](#prepare-the-server).
<a name="setup-the-domain-name"></a>

View File

@ -0,0 +1,23 @@
# Single-Sign-On authentication using OpenID Connect
Configuration of an OpenID Connect provider is designed to be easier than the OAuth 2.0 authentication method.
Nevertheless, it is less powerful and allows only limited fields mapping to the OpenID `userinfo` endpoint.
We highly recommend using the [Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) mechanism to get the configuration of the OpenID Connect provider.
When configuring an authentication provider using the OpenID Connect protocol, the following fields can be mapped automatically
to the corresponding OpenID Connect claims:
- user.uid
- user.email
- user.username
- profile.first_name
- profile.last_name
- profile.avatar
- profile.website
- profile.gender
- profile.birthday
- profile.phone
- profile.address
To use the automatic mapping, add one of the fields above and click on the magic wand button near to the "Userinfo claim" input.

View File

@ -5,27 +5,23 @@ For this guide, we will use [GitHub](https://developer.github.com/v3/oauth/) as
- First, you must have a GitHub account. This is free, so create one if you don't have any.
Visit https://github.com/join?source=login to create an account.
- Secondly, you will need to register your Fab-manager instance as an application in GitHub.
Visit https://github.com/settings/applications/new to register your instance.
- In `Application name`, we advise you to set the same name as your Fab-manager's instance title.
- In `Homepage URL`, put the public URL where your Fab-manager's instance is located (eg. https://example.com).
- In `Authorization callback URL`, you must specify an URL that will match this scheme: https://example.com/users/auth/oauth2-github/callback
- **example.com** is your own Fab-manager's address
- **oauth2-github** match the provider's "strategy name" in the Fab-manager.
It is composed of: **SSO's protocol**, _dash_, **slug of the provider's name**.
If you have a doubt about what it will be, set any value, create the authentication provider in your Fab-manager (see below), then the strategy's name will be shown in the providers list.
Afterwards, edit your app on GitHub to set the correct name.
- You'll be redirected to a page displaying two important information: your **Client ID** and your **Client Secret**.
- Now go to your Fab-manager's instance, login as an administrator, go to `Users management` and `Authentication`.
- Go to your Fab-manager's instance, login as an administrator, go to `Users management` and `Authentication`.
Click `Add a new authentication provider`, and select _OAuth 2.0_ in the `Authentication type` drop-down list.
In `name`, you can set whatever you want, but you must be aware that:
1. You will need to type this name in a terminal to activate the provider, so prefer avoiding chars that must be escaped.
2. This name will be occasionally displayed to end users, so prefer sweet and speaking names.
3. The slug of this name is used in the callback URL provided to the SSO server (eg. /users/auth/oauth2-**github**/callback)
- Fulfill the form with the following parameters:
- You'll see an "Authorization Callback URL" field, generated based on what you typed previously. Copy the content of this field to your clipboard.
- Now, you will need to register your Fab-manager instance as an application in GitHub.
Visit https://github.com/settings/applications/new to register your instance.
- In `Application name`, we advise you to set the same name as your Fab-manager's instance title.
- In `Homepage URL`, put the public URL where your Fab-manager's instance is located (eg. https://example.com).
- In `Authorization callback URL`, you must paste the URL previously copied from Fa-manager.
- You'll be redirected to a page displaying two important information: your **Client ID** and your **Client Secret**. Keep them safe, you'll need them to configure Fab-manager.
- Now go back to your Fab-manager's configuration interface and fulfill the remaining form with the following parameters:
- **Server root URL**: `https://github.com` This is the domain name of the where the SSO server is located.
- **Authorization endpoint**: `/login/oauth/authorize` This URL can be found [here](https://developer.github.com/v3/oauth/).
- **Token Acquisition Endpoint**: `/login/oauth/access_token` This URL can be found [here](https://developer.github.com/v3/oauth/).
@ -33,7 +29,7 @@ For this guide, we will use [GitHub](https://developer.github.com/v3/oauth/) as
- **Client identifier**: Your Client ID, collected just before.
- **Client secret**: Your Client Secret, collected just before.
Please note that in some cases we'll encounter an issue unless the **common URL** must only contain the root domain (e.g. `http://github.com`), and the other parts of the URL must go to **Authorization endpoint** (e.g. `/login/oauth/authorize`) and **Token Acquisition Endpoint** (e.g. `/login/oauth/access_token`).
Please note the **common URL** must only contain the root domain (e.g. `http://github.com`), and the other parts of the URL must go to **Authorization endpoint** (e.g. `/login/oauth/authorize`) and **Token Acquisition Endpoint** (e.g. `/login/oauth/access_token`).
- Then you will need to define the matching of the fields between the Fab-manager and what the external SSO can provide.
Please note that the only mandatory field is `User.uid`.

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# Data mapping functions for SSO authentications (through OmniAuth)
module OmniAuth::DataMapping
# Type-dependant mapping functions
module Base
extend ActiveSupport::Concern
included do
def local_sym(mapping)
(mapping.local_model + '.' + mapping.local_field).to_sym
end
def map_transformation(transformation, raw_data)
value = nil
transformation['mapping'].each do |m|
if m['from'] == raw_data
value = m['to']
break
end
end
# if no transformation had set any value, return the raw value
value || raw_data
end
def map_boolean(transformation, raw_data)
return false if raw_data == transformation['false_value']
true if raw_data == transformation['true_value']
end
def map_date(transformation, raw_data)
case transformation['format']
when 'iso8601'
DateTime.iso8601(raw_data)
when 'rfc2822'
DateTime.rfc2822(raw_data)
when 'rfc3339'
DateTime.rfc3339(raw_data)
when 'timestamp-s'
DateTime.strptime(raw_data, '%s')
when 'timestamp-ms'
DateTime.strptime(raw_data, '%Q')
else
DateTime.parse(raw_data)
end
end
end
end
end

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
module OmniAuth::DataMapping
# Build the data mapping for the given provider
module Mapper
extend ActiveSupport::Concern
included do
require 'sso_logger'
require_relative 'base'
include OmniAuth::DataMapping::Base
def mapped_info(mappings, raw_info)
logger = SsoLogger.new
@info ||= {}
logger.debug "[mapped_info] @info = #{@info.to_json}"
unless @info.size.positive?
mappings.each do |mapping|
raw_data = ::JsonPath.new(mapping.api_field).on(raw_info[mapping.api_endpoint.to_sym]).first
logger.debug "@parsed_info[#{local_sym(mapping)}] mapped from #{raw_data}"
@info[local_sym(mapping)] = if mapping.transformation
case mapping.transformation['type']
when 'integer'
map_transformation(mapping.transformation, raw_data)
when 'boolean'
map_boolean(mapping.transformation, raw_data)
when 'date'
map_date(mapping.transformation, raw_data)
when 'string'
map_transformation(mapping.transformation, raw_data)
else
# other unsupported transformation
raw_data
end
else
raw_data
end
end
end
@info
end
end
end
end

3
lib/omni_auth/oauth2.rb Normal file
View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
require_relative 'strategies/sso_oauth2_provider'

View File

@ -1,7 +0,0 @@
# frozen_string_literal: true
active_provider = AuthProvider.active
if active_provider.providable_type != DatabaseProvider.name
require_relative "strategies/sso_#{active_provider.provider_type}_provider"
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
require_relative 'strategies/sso_openid_connect_provider'

View File

@ -3,10 +3,12 @@
require 'omniauth-oauth2'
require 'jsonpath'
require 'sso_logger'
require_relative '../data_mapping/mapper'
module OmniAuth::Strategies
# Authentication strategy provided trough oAuth 2.0
class SsoOauth2Provider < OmniAuth::Strategies::OAuth2
include OmniAuth::DataMapping::Mapper
def self.active_provider
active_provider = AuthProvider.active
@ -58,7 +60,7 @@ module OmniAuth::Strategies
@raw_info ||= {}
logger.debug "[raw_info] @raw_infos = #{@raw_info&.to_json}"
unless @raw_info.size.positive?
OmniAuth::Strategies::SsoOauth2Provider.active_provider.providable.o_auth2_mappings.each do |mapping|
OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings.each do |mapping|
logger.debug "mapping = #{mapping&.to_json}"
next if @raw_info.key?(mapping.api_endpoint.to_sym)
@ -73,82 +75,7 @@ module OmniAuth::Strategies
end
def parsed_info
logger = SsoLogger.new
@parsed_info ||= {}
logger.debug "[parsed_info] @parsed_info = #{@parsed_info.to_json}"
unless @parsed_info.size.positive?
OmniAuth::Strategies::SsoOauth2Provider.active_provider.providable.o_auth2_mappings.each do |mapping|
raw_data = ::JsonPath.new(mapping.api_field).on(raw_info[mapping.api_endpoint.to_sym]).first
logger.debug "@parsed_info[#{local_sym(mapping)}] mapped from #{raw_data}"
if mapping.transformation
case mapping.transformation['type']
## INTEGER
when 'integer'
@parsed_info[local_sym(mapping)] = map_integer(mapping.transformation, raw_data)
## BOOLEAN
when 'boolean'
@parsed_info[local_sym(mapping)] = map_boolean(mapping.transformation, raw_data)
## DATE
when 'date'
@params[local_sym(mapping)] = map_date(mapping.transformation, raw_data)
## OTHER TRANSFORMATIONS (not supported)
else
@parsed_info[local_sym(mapping)] = raw_data
end
## NO TRANSFORMATION
else
@parsed_info[local_sym(mapping)] = raw_data
end
end
end
@parsed_info
end
private
def local_sym(mapping)
(mapping.local_model + '.' + mapping.local_field).to_sym
end
def map_integer(transformation, raw_data)
value = nil
transformation['mapping'].each do |m|
if m['from'] == raw_data
value = m['to']
break
end
end
# if no transformation had set any value, return the raw value
value || raw_data
end
def map_boolean(transformation, raw_data)
return false if raw_data == transformation['false_value']
true if raw_data == transformation['true_value']
end
def map_date(transformation, raw_data)
case transformation['format']
when 'iso8601'
DateTime.iso8601(raw_data)
when 'rfc2822'
DateTime.rfc2822(raw_data)
when 'rfc3339'
DateTime.rfc3339(raw_data)
when 'timestamp-s'
DateTime.strptime(raw_data, '%s')
when 'timestamp-ms'
DateTime.strptime(raw_data, '%Q')
else
DateTime.parse(raw_data)
end
mapped_info(OmniAuth::Strategies::SsoOauth2Provider.active_provider.auth_provider_mappings, raw_info)
end
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'omniauth_openid_connect'
require_relative '../data_mapping/mapper'
module OmniAuth::Strategies
# Authentication strategy provided trough OpenID Connect
class SsoOpenidConnectProvider < OmniAuth::Strategies::OpenIDConnect
include OmniAuth::DataMapping::Mapper
def self.active_provider
active_provider = AuthProvider.active
if active_provider.providable_type != OpenIdConnectProvider.name
raise "Trying to instantiate the wrong provider: Expected OpenIdConnectProvider, received #{active_provider.providable_type}"
end
active_provider
end
# Strategy name.
option :name, active_provider.strategy_name
info do
{
mapping: parsed_info
}
end
def parsed_info
mapped_info(
OmniAuth::Strategies::SsoOpenidConnectProvider.active_provider.auth_provider_mappings,
user_info: user_info.raw_attributes
)
end
end
end

View File

@ -6,20 +6,27 @@ namespace :fablab do
desc 'switch the active authentication provider'
task :switch_provider, [:provider] => :environment do |_task, args|
raise 'FATAL ERROR: You must pass a provider name to activate' unless args.provider
unless args.provider
puts "\e[0;31mERROR\e[0m: You must pass a provider name to activate"
next
end
if AuthProvider.find_by(name: args.provider).nil?
providers = AuthProvider.all.inject('') { |str, item| str + item[:name] + ', ' }
raise "FATAL ERROR: the provider '#{args.provider}' does not exists. Available providers are: #{providers[0..-3]}"
puts "\e[0;31mERROR\e[0m: the provider '#{args.provider}' does not exists. Available providers are: #{providers[0..-3]}"
next
end
raise "FATAL ERROR: the provider '#{args.provider}' is already enabled" if AuthProvider.active.name == args.provider
if AuthProvider.active.name == args.provider
puts "\e[0;31mERROR\e[0m: the provider '#{args.provider}' is already enabled"
next
end
# disable previous provider
prev_prev = AuthProvider.previous
prev_prev&.update_attribute(:status, 'pending')
AuthProvider.active.update_attribute(:status, 'previous')
AuthProvider.active.update_attribute(:status, 'previous') unless AuthProvider.active.name == 'DatabaseProvider::SimpleAuthProvider'
# enable given provider
AuthProvider.find_by(name: args.provider).update_attribute(:status, 'active')
@ -38,14 +45,11 @@ namespace :fablab do
# ask the user to restart the application
next if Rails.env.test?
puts "\nActivation successful"
puts "\n/!\\ WARNING: Please consider the following, otherwise the authentication will be bogus:"
puts "\t1) CLEAN the cache with `rails tmp:clear`"
puts "\t2) REBUILD the assets with `rails assets:precompile`"
puts "\t3) RESTART the application"
puts "\t4) NOTIFY the current users with `rails fablab:auth:notify_changed`\n\n"
puts "\n\e[0;32m#{args.provider} successfully enabled\e[0m"
puts "\n\e[0;33m⚠ WARNING\e[0m: Please consider the following, otherwise the authentication will be bogus:"
puts "\t1) RESTART the application"
puts "\t2) NOTIFY the current users with `rails fablab:auth:notify_changed`\n\n"
end
desc 'notify users that the auth provider has changed'
@ -64,5 +68,10 @@ namespace :fablab do
puts "\nUsers successfully notified\n\n"
end
desc 'display the current active authentication provider'
task current: :environment do
puts "Current active authentication provider: #{AuthProvider.active.name}"
end
end
end

View File

@ -135,7 +135,7 @@
"react-hook-form": "^7.25.3",
"react-i18next": "^11.15.6",
"react-modal": "^3.11.2",
"react-select": "^4.3.1",
"react-select": "^5.2.2",
"react-switch": "^6.0.0",
"react2angular": "^4.0.6",
"resolve-url-loader": "^4.0.0",

View File

@ -22,8 +22,9 @@ class AuthProvidersTest < ActionDispatch::IntegrationTest
base_url: 'https://github.com/login/oauth/',
profile_url: 'https://github.com/settings/profile',
client_id: ENV.fetch('OAUTH_CLIENT_ID') { 'github-oauth-app-id' },
client_secret: ENV.fetch('OAUTH_CLIENT_SECRET') { 'github-oauth-app-secret' },
o_auth2_mappings_attributes: [
client_secret: ENV.fetch('OAUTH_CLIENT_SECRET') { 'github-oauth-app-secret' }
},
auth_provider_mappings_attributes: [
{
api_data_type: 'json',
api_endpoint: 'https://api.github.com/user',
@ -40,7 +41,6 @@ class AuthProvidersTest < ActionDispatch::IntegrationTest
}
]
}
}
}.to_json,
headers: default_headers
@ -56,7 +56,7 @@ class AuthProvidersTest < ActionDispatch::IntegrationTest
assert_equal name, provider[:name]
assert_equal db_provider.id, provider[:id]
assert_equal 'pending', provider[:status]
assert_equal 2, provider[:providable_attributes][:o_auth2_mappings_attributes].length
assert_equal 2, provider[:auth_provider_mappings_attributes].length
# now let's activate this new provider
Fablab::Application.load_tasks if Rake::Task.tasks.empty?

View File

@ -1909,6 +1909,13 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.0":
version "4.4.4"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e"
integrity sha512-7gAPz7anVK5xzbeQW9wFBDg7G++aPLAFY0QaSMOou9rJZpbuI58WAuJrgu+qR92l61grlnCUe7AFX8KGahAgug==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17.0.3":
version "17.0.11"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
@ -5843,7 +5850,7 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -6042,13 +6049,6 @@ react-i18next@^11.15.6:
html-escaper "^2.0.2"
html-parse-stringify "^3.0.1"
react-input-autosize@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-3.0.0.tgz#6b5898c790d4478d69420b55441fcc31d5c50a85"
integrity sha512-nL9uS7jEs/zu8sqwFE5MAPx6pPkNAriACQ2rGLlqmKr2sPGtN7TXTyDdQt4lbNXVx7Uzadb40x8qotIuru6Rhg==
dependencies:
prop-types "^15.5.8"
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -6074,17 +6074,17 @@ react-refresh@^0.11.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"
integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==
react-select@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-4.3.1.tgz#389fc07c9bc7cf7d3c377b7a05ea18cd7399cb81"
integrity sha512-HBBd0dYwkF5aZk1zP81Wx5UsLIIT2lSvAY2JiJo199LjoLHoivjn9//KsmvQMEFGNhe58xyuOITjfxKCcGc62Q==
react-select@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.2.2.tgz#3d5edf0a60f1276fd5f29f9f90a305f0a25a5189"
integrity sha512-miGS2rT1XbFNjduMZT+V73xbJEeMzVkJOz727F6MeAr2hKE0uUSA8Ff7vD44H32x2PD3SRB6OXTY/L+fTV3z9w==
dependencies:
"@babel/runtime" "^7.12.0"
"@emotion/cache" "^11.4.0"
"@emotion/react" "^11.1.1"
"@types/react-transition-group" "^4.4.0"
memoize-one "^5.0.0"
prop-types "^15.6.0"
react-input-autosize "^3.0.0"
react-transition-group "^4.3.0"
react-switch@^6.0.0: