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:
commit
0dd6f4bff5
@ -15,7 +15,9 @@
|
||||
"moment": true,
|
||||
"_": true,
|
||||
"Humanize": true,
|
||||
"GTM": true
|
||||
"GTM": true,
|
||||
"$": true,
|
||||
"KeyboardEvent": true
|
||||
},
|
||||
"plugins": ["html-erb"],
|
||||
"overrides": [
|
||||
|
@ -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
|
||||
|
1
Gemfile
1
Gemfile
@ -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'
|
||||
|
42
Gemfile.lock
42
Gemfile.lock
@ -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
|
||||
|
@ -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]]]])
|
||||
params.require(:auth_provider)
|
||||
.permit(:name, :providable_type,
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -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, /.*/));
|
||||
|
39
app/frontend/src/javascript/api/auth-provider.ts
Normal file
39
app/frontend/src/javascript/api/auth-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
9
app/frontend/src/javascript/api/clients/sso-client.ts
Normal file
9
app/frontend/src/javascript/api/clients/sso-client.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
function client (host: string): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: host
|
||||
});
|
||||
}
|
||||
|
||||
export default client;
|
13
app/frontend/src/javascript/api/external/sso.ts
vendored
Normal file
13
app/frontend/src/javascript/api/external/sso.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
17
app/frontend/src/javascript/components/README.md
Normal file
17
app/frontend/src/javascript/components/README.md
Normal 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.
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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} > {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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
8
app/frontend/src/javascript/components/form/README.md
Normal file
8
app/frontend/src/javascript/components/form/README.md
Normal 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.
|
87
app/frontend/src/javascript/components/form/form-input.tsx
Normal file
87
app/frontend/src/javascript/components/form/form-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
82
app/frontend/src/javascript/components/form/form-select.tsx
Normal file
82
app/frontend/src/javascript/components/form/form-select.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
@ -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) {
|
||||
@ -40,7 +27,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
|
||||
// Fab-manager's app-version
|
||||
if (user.role === 'admin') {
|
||||
// get the version
|
||||
$scope.version = Version.get({origin: window.location.origin});
|
||||
$scope.version = Version.get({ origin: window.location.origin });
|
||||
} else {
|
||||
$scope.version = { current: '' };
|
||||
}
|
||||
@ -83,111 +70,116 @@ 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 %>
|
||||
$window.location.href = '/sso-redirect';
|
||||
<% 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; }]
|
||||
},
|
||||
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
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay,
|
||||
maxDate: new Date()
|
||||
}
|
||||
};
|
||||
|
||||
// is the phone number required to sign-up?
|
||||
$scope.phoneRequired = (settingsPromise.phone_required === 'true');
|
||||
|
||||
// is the address required to sign-up?
|
||||
$scope.addressRequired = (settingsPromise.address_required === 'true');
|
||||
|
||||
// reCaptcha v2 site key (or undefined)
|
||||
$scope.recaptchaSiteKey = settingsPromise.recaptcha_site_key;
|
||||
|
||||
// callback to open the date picker (account creation modal)
|
||||
$scope.openDatePicker = function ($event) {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
return $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; });
|
||||
});
|
||||
|
||||
// retrieve the CGU
|
||||
CustomAsset.get({ name: 'cgu-file' }, function (cgu) {
|
||||
$scope.cgu = cgu.custom_asset;
|
||||
});
|
||||
|
||||
// default user's parameters
|
||||
$scope.user = {
|
||||
is_allow_contact: true,
|
||||
is_allow_newsletter: false,
|
||||
// reCaptcha response, received from Google (through AJAX) and sent to server for validation
|
||||
recaptcha: undefined
|
||||
};
|
||||
|
||||
// Errors display
|
||||
$scope.alerts = [];
|
||||
$scope.closeAlert = function (index) {
|
||||
$scope.alerts.splice(index, 1);
|
||||
};
|
||||
|
||||
// callback for form validation
|
||||
$scope.ok = function () {
|
||||
// try to create the account
|
||||
$scope.alerts = [];
|
||||
// remove 'organization' attribute
|
||||
const orga = $scope.user.organization;
|
||||
delete $scope.user.organization;
|
||||
// register on server
|
||||
return Auth.register($scope.user).then(function (user) {
|
||||
if (user.id) {
|
||||
// creation successful
|
||||
$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'));
|
||||
if (Fablab.activeProviderType !== 'DatabaseProvider') {
|
||||
$window.location.href = '/sso-redirect';
|
||||
} 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;
|
||||
}]
|
||||
},
|
||||
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
|
||||
$scope.datePicker = {
|
||||
format: Fablab.uibDateFormat,
|
||||
opened: false,
|
||||
options: {
|
||||
startingDay: Fablab.weekStartingDay,
|
||||
maxDate: new Date()
|
||||
}
|
||||
}, function (error) {
|
||||
// creation failed...
|
||||
// restore organization param
|
||||
$scope.user.organization = orga;
|
||||
// display errors
|
||||
angular.forEach(error.data.errors, function (v, k) {
|
||||
angular.forEach(v, function (err) {
|
||||
$scope.alerts.push({
|
||||
msg: k + ': ' + err,
|
||||
type: 'danger'
|
||||
};
|
||||
|
||||
// is the phone number required to sign-up?
|
||||
$scope.phoneRequired = (settingsPromise.phone_required === 'true');
|
||||
|
||||
// is the address required to sign-up?
|
||||
$scope.addressRequired = (settingsPromise.address_required === 'true');
|
||||
|
||||
// reCaptcha v2 site key (or undefined)
|
||||
$scope.recaptchaSiteKey = settingsPromise.recaptcha_site_key;
|
||||
|
||||
// callback to open the date picker (account creation modal)
|
||||
$scope.openDatePicker = function ($event) {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
$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;
|
||||
});
|
||||
});
|
||||
|
||||
// retrieve the CGU
|
||||
CustomAsset.get({ name: 'cgu-file' }, function (cgu) {
|
||||
$scope.cgu = cgu.custom_asset;
|
||||
});
|
||||
|
||||
// default user's parameters
|
||||
$scope.user = {
|
||||
is_allow_contact: true,
|
||||
is_allow_newsletter: false,
|
||||
// reCaptcha response, received from Google (through AJAX) and sent to server for validation
|
||||
recaptcha: undefined
|
||||
};
|
||||
|
||||
// Errors display
|
||||
$scope.alerts = [];
|
||||
$scope.closeAlert = function (index) {
|
||||
$scope.alerts.splice(index, 1);
|
||||
};
|
||||
|
||||
// callback for form validation
|
||||
$scope.ok = function () {
|
||||
// try to create the account
|
||||
$scope.alerts = [];
|
||||
// remove 'organization' attribute
|
||||
const orga = $scope.user.organization;
|
||||
delete $scope.user.organization;
|
||||
// register on server
|
||||
return Auth.register($scope.user).then(function (user) {
|
||||
if (user.id) {
|
||||
// creation successful
|
||||
$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'));
|
||||
}
|
||||
}, function (error) {
|
||||
// creation failed...
|
||||
// restore organization param
|
||||
$scope.user.organization = orga;
|
||||
// display errors
|
||||
angular.forEach(error.data.errors, function (v, k) {
|
||||
angular.forEach(v, function (err) {
|
||||
$scope.alerts.push({
|
||||
msg: k + ': ' + err,
|
||||
type: 'danger'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}]
|
||||
}).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;
|
||||
growl.info(_t('app.public.common.you_will_receive_confirmation_instructions_by_email_detailed'));
|
||||
} else {
|
||||
$scope.setCurrentUser(res.user);
|
||||
}
|
||||
});
|
||||
<% end %>
|
||||
};
|
||||
}]
|
||||
}).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;
|
||||
growl.info(_t('app.public.common.you_will_receive_confirmation_instructions_by_email_detailed'));
|
||||
} else {
|
||||
$scope.setCurrentUser(res.user);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -281,7 +273,7 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
|
||||
/**
|
||||
* Open the modal dialog showing that an upgrade is available
|
||||
*/
|
||||
$scope.versionModal = function() {
|
||||
$scope.versionModal = function () {
|
||||
if ($scope.version.up_to_date) return;
|
||||
if ($rootScope.currentUser.role !== 'admin') return;
|
||||
|
||||
@ -289,10 +281,10 @@ Application.Controllers.controller('ApplicationController', ['$rootScope', '$sco
|
||||
templateUrl: '/admin/versions/upgradeModal.html',
|
||||
controller: 'VersionModalController',
|
||||
resolve: {
|
||||
version() { return $scope.version; }
|
||||
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,116 +397,126 @@ 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 %>
|
||||
$window.location.href = '/sso-redirect';
|
||||
<% else %>
|
||||
return $uibModal.open({
|
||||
templateUrl: '/shared/deviseModal.html',
|
||||
backdrop: 'static',
|
||||
size: 'sm',
|
||||
resolve: {
|
||||
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 = {});
|
||||
if (Fablab.activeProviderType !== 'DatabaseProvider') {
|
||||
$window.location.href = '/sso-redirect';
|
||||
} else {
|
||||
return $uibModal.open({
|
||||
templateUrl: '/shared/deviseModal.html',
|
||||
backdrop: 'static',
|
||||
size: 'sm',
|
||||
resolve: {
|
||||
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 = {});
|
||||
|
||||
// email confirmation required before user sign-in?
|
||||
$scope.confirmationRequired = settingsPromise.confirmation_required;
|
||||
// email confirmation required before user sign-in?
|
||||
$scope.confirmationRequired = settingsPromise.confirmation_required;
|
||||
|
||||
$scope.login = function () {
|
||||
Auth.login(user).then(function (user) {
|
||||
// Authentication succeeded ...
|
||||
$uibModalInstance.close(user);
|
||||
if (callback && (typeof callback === 'function')) {
|
||||
return callback(user);
|
||||
$scope.login = function () {
|
||||
Auth.login(user).then(function (user) {
|
||||
// Authentication succeeded ...
|
||||
$uibModalInstance.close(user);
|
||||
if (callback && (typeof callback === 'function')) {
|
||||
return callback(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
, function (error) {
|
||||
console.error(`Authentication failed: ${JSON.stringify(error)}`);
|
||||
$scope.alerts = [];
|
||||
return $scope.alerts.push({
|
||||
msg: error.data.error,
|
||||
type: 'danger'
|
||||
, function (error) {
|
||||
console.error(`Authentication failed: ${JSON.stringify(error)}`);
|
||||
$scope.alerts = [];
|
||||
return $scope.alerts.push({
|
||||
msg: error.data.error,
|
||||
type: 'danger'
|
||||
});
|
||||
});
|
||||
};
|
||||
// handle modal behaviors. The provided reason will be used to define the following actions
|
||||
$scope.dismiss = function () {
|
||||
$uibModalInstance.dismiss('cancel');
|
||||
};
|
||||
|
||||
$scope.openSignup = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('signup');
|
||||
};
|
||||
|
||||
$scope.openConfirmationNewModal = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('confirmationNew');
|
||||
};
|
||||
|
||||
$scope.openResetPassword = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('resetPassword');
|
||||
};
|
||||
}]
|
||||
}).result.finally(null).then(function (user) {
|
||||
// what to do when the modal is closed
|
||||
|
||||
// authentication succeeded, set the session, gather the notifications and redirect
|
||||
GTM.trackLogin();
|
||||
$scope.setCurrentUser(user);
|
||||
|
||||
if ((toState !== null) && (toParams !== null)) {
|
||||
return $state.go(toState, toParams);
|
||||
}
|
||||
}, function (reason) {
|
||||
// authentication did not end successfully
|
||||
if (reason === 'signup') {
|
||||
// open sign-up modal
|
||||
$scope.signup();
|
||||
} else if (reason === 'resetPassword') {
|
||||
// open the 'reset password' modal
|
||||
return $uibModal.open({
|
||||
templateUrl: '/shared/passwordNewModal.html',
|
||||
size: 'sm',
|
||||
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
|
||||
$scope.user = { email: '' };
|
||||
$scope.sendReset = function () {
|
||||
$scope.alerts = [];
|
||||
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'
|
||||
});
|
||||
});
|
||||
};
|
||||
}]
|
||||
}).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'));
|
||||
});
|
||||
};
|
||||
// handle modal behaviors. The provided reason will be used to define the following actions
|
||||
$scope.dismiss = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
|
||||
$scope.openSignup = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('signup');
|
||||
};
|
||||
|
||||
$scope.openConfirmationNewModal = function(e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('confirmationNew');
|
||||
};
|
||||
|
||||
$scope.openResetPassword = function (e) {
|
||||
e.preventDefault();
|
||||
return $uibModalInstance.dismiss('resetPassword');
|
||||
};
|
||||
}]
|
||||
}).result['finally'](null).then(function (user) {
|
||||
// what to do when the modal is closed
|
||||
|
||||
// authentication succeeded, set the session, gather the notifications and redirect
|
||||
GTM.trackLogin();
|
||||
$scope.setCurrentUser(user);
|
||||
|
||||
if ((toState !== null) && (toParams !== null)) {
|
||||
return $state.go(toState, toParams);
|
||||
}
|
||||
}, function (reason) {
|
||||
// authentication did not end successfully
|
||||
if (reason === 'signup') {
|
||||
// open sign-up modal
|
||||
$scope.signup();
|
||||
} else if (reason === 'resetPassword') {
|
||||
// open the 'reset password' modal
|
||||
return $uibModal.open({
|
||||
templateUrl: '/shared/passwordNewModal.html',
|
||||
size: 'sm',
|
||||
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
|
||||
$scope.user = { email: '' };
|
||||
return $scope.sendReset = function () {
|
||||
$scope.alerts = [];
|
||||
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'
|
||||
} else if (reason === 'confirmationNew') {
|
||||
// open the 'reset password' modal
|
||||
return $uibModal.open({
|
||||
templateUrl: '/shared/ConfirmationNewModal.html',
|
||||
size: 'sm',
|
||||
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
|
||||
$scope.user = { email: '' };
|
||||
$scope.submitConfirmationNewForm = function () {
|
||||
$scope.alerts = [];
|
||||
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'
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
}]
|
||||
}).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({
|
||||
templateUrl: '/shared/ConfirmationNewModal.html',
|
||||
size: 'sm',
|
||||
controller: ['$scope', '$uibModalInstance', '$http', function ($scope, $uibModalInstance, $http) {
|
||||
$scope.user = { email: '' };
|
||||
return $scope.submitConfirmationNewForm = function () {
|
||||
$scope.alerts = [];
|
||||
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'
|
||||
});
|
||||
});
|
||||
};
|
||||
}]
|
||||
}).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 %>
|
||||
};
|
||||
}]
|
||||
}).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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
}]);
|
@ -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]>
|
||||
}
|
28
app/frontend/src/javascript/models/form-component.ts
Normal file
28
app/frontend/src/javascript/models/form-component.ts
Normal 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>;
|
||||
}
|
15
app/frontend/src/javascript/models/sso.ts
Normal file
15
app/frontend/src/javascript/models/sso.ts
Normal 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[]
|
||||
}
|
@ -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; }]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
.data-mapping-form {
|
||||
.mapping-item .inputs .form-item {
|
||||
margin-left: 20px;
|
||||
}
|
||||
.local-data,
|
||||
.remote-data > *{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
.openid-connect-data-mapping-form {
|
||||
.auto-configure-button {
|
||||
align-self: center;
|
||||
margin-top: 0.8rem;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
164
app/frontend/src/stylesheets/modules/form/form-item.scss
Normal file
164
app/frontend/src/stylesheets/modules/form/form-item.scss
Normal 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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
|
7
app/models/auth_provider_mapping.rb
Normal file
7
app/models/auth_provider_mapping.rb
Normal 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
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Read and write the amount attribute, after converting to/from cents.
|
||||
module AmountConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
162
app/models/concerns/single_sign_on_concern.rb
Normal file
162
app/models/concerns/single_sign_on_concern.rb
Normal 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
|
@ -1,5 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Defines the standard statistics data model.
|
||||
module StatConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
@ -1,3 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Defines the reservation statistics data model
|
||||
module StatReservationConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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
|
@ -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
|
||||
|
69
app/models/open_id_connect_provider.rb
Normal file
69
app/models/open_id_connect_provider.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
10
app/validators/database_provider_validator.rb
Normal file
10
app/validators/database_provider_validator.rb
Normal 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
|
14
app/validators/user_uid_mapped_validator.rb
Normal file
14
app/validators/user_uid_mapped_validator.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
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
|
||||
|
@ -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 %>";
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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}"
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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
|
@ -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
|
@ -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
|
76
db/schema.rb
76
db/schema.rb
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2022_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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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>
|
||||
|
23
doc/sso_open_id_connect.md
Normal file
23
doc/sso_open_id_connect.md
Normal 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.
|
@ -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`.
|
||||
|
50
lib/omni_auth/data_mapping/base.rb
Normal file
50
lib/omni_auth/data_mapping/base.rb
Normal 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
|
47
lib/omni_auth/data_mapping/mapper.rb
Normal file
47
lib/omni_auth/data_mapping/mapper.rb
Normal 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
3
lib/omni_auth/oauth2.rb
Normal file
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'strategies/sso_oauth2_provider'
|
@ -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
|
3
lib/omni_auth/openid_connect.rb
Normal file
3
lib/omni_auth/openid_connect.rb
Normal file
@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative 'strategies/sso_openid_connect_provider'
|
@ -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
|
||||
|
36
lib/omni_auth/strategies/sso_openid_connect_provider.rb
Normal file
36
lib/omni_auth/strategies/sso_openid_connect_provider.rb
Normal 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
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -22,24 +22,24 @@ 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: [
|
||||
{
|
||||
api_data_type: 'json',
|
||||
api_endpoint: 'https://api.github.com/user',
|
||||
api_field: 'id',
|
||||
local_field: 'uid',
|
||||
local_model: 'user'
|
||||
},
|
||||
{
|
||||
api_data_type: 'json',
|
||||
api_endpoint: 'https://api.github.com/user',
|
||||
api_field: 'html_url',
|
||||
local_field: 'github',
|
||||
local_model: 'profile'
|
||||
}
|
||||
]
|
||||
}
|
||||
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',
|
||||
api_field: 'id',
|
||||
local_field: 'uid',
|
||||
local_model: 'user'
|
||||
},
|
||||
{
|
||||
api_data_type: 'json',
|
||||
api_endpoint: 'https://api.github.com/user',
|
||||
api_field: 'html_url',
|
||||
local_field: 'github',
|
||||
local_model: 'profile'
|
||||
}
|
||||
]
|
||||
}
|
||||
}.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?
|
||||
|
26
yarn.lock
26
yarn.lock
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user