1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2024-12-01 12:24:28 +01:00

Merge branch 'v5.4' into dev

This commit is contained in:
Sylvain 2022-05-11 17:06:33 +02:00
commit 1b35dfcc0f
433 changed files with 12984 additions and 4043 deletions

View File

@ -31,6 +31,9 @@ imports
# accounting archives
accounting
# Proof of identity files
proof_of_identity_files
# Development files
Vagrantfile
provision

View File

@ -15,9 +15,11 @@
"moment": true,
"_": true,
"Humanize": true,
"GTM": true
"GTM": true,
"$": true,
"KeyboardEvent": true
},
"plugins": ["lint-erb"],
"plugins": ["html-erb"],
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],

3
.gitignore vendored
View File

@ -46,6 +46,9 @@
# Archives of closed accounting periods
/accounting/*
# Proof of identity files
/proof_of_identity_files/*
.DS_Store
.vagrant

2
.nvmrc
View File

@ -1 +1 @@
12.18.3
16.13.2

View File

@ -2,10 +2,44 @@
## next deploy
- when generating an avoir, the option "by_wallet" is not present anymore if wallet module is off
- Option to disable the 'machines' module
- Option to prevent users from changing their group
- Ability to define social networks for the FabLab "about page"
- Improved security when changing passwords
- Support for OpenID Connect in Single-Sign-On authentication providers
- ICS file attached to the reservation notification email
- Refactored the user profile edition form
- Improved the profile completion page
- When generating an avoir, the option "by_wallet" is not present anymore if wallet module is disabled
- 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
- Use of CSS variables for main and secondary colors in react components styles
- New text editor
- Change font family to "Work Sans"
- 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
- Updated sidekiq-scheduler to v4.0.0
- Updated icalendar to 2.7.1
- Webpack overlay will now report eslint issues
- Linted all code according to eslint rules
- Fix a bug: when enabled, the statistics module is still not shown in the menu
- Feature User validation, an option for allow admin validate account of user to active/disable the reservation
- Feature Proof of identity by group, allow user upload his proof of identity and admin can check it for validate the account of user
- Feature Organization custom fields
- Fix a bug: Refused to connect to 'wss://localhost:3035/ws' when using a https tunnel in development mode
- Fix a bug: edge case of birthday in the future in seeds.rb, we should use Date.current instead of DateTime.current since birthday is a date (see https://github.com/sleede/fab-manager/issues/344)
- Fix a security issue: updated ruby to 2.6.10 to fix [CVE-2022-28739](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-28739)
- Fix a security issue: updated rails to 5.2.7.1 to fix [CVE-2022-22577](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22577) and [CVE-2022-27777](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-27777)
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] add the `MAX_PROOF_OF_IDENTITY_FILE_SIZE` environment variable (see [doc/environment.md](doc/environment.md#MAX_PROOF_OF_IDENTITY_FILE_SIZE) for configuration details)
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-proof-of-identity-files.sh | bash`
- [TODO DEPLOY] `rails db:migrate`
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] -> (only dev) `bundle install`
## v5.3.13 2022 May 02
@ -444,7 +478,7 @@
## v4.7.14 2021 September 30
- Fix a bug: updated sassc to 2.4.0 to fix ruby runtime error on some CPU architectures
- Fix a bug: update sassc to 2.4.0 to try to fix #270 (ruby runtime error on some CPU architectures)
## v4.7.13 2021 June 11

View File

@ -66,6 +66,7 @@ RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/public/uploads && \
mkdir -p /usr/src/app/public/packs && \
mkdir -p /usr/src/app/accounting && \
mkdir -p /usr/src/app/proof_of_identity_files && \
mkdir -p /usr/src/app/tmp/sockets && \
mkdir -p /usr/src/app/tmp/pids
@ -81,6 +82,7 @@ VOLUME /usr/src/app/public
VOLUME /usr/src/app/public/uploads
VOLUME /usr/src/app/public/packs
VOLUME /usr/src/app/accounting
VOLUME /usr/src/app/proof_of_identity_files
VOLUME /var/log/supervisor
# Expose port 3000 to the Docker host, so we can access it from the outside

View File

@ -69,6 +69,7 @@ gem 'devise', '>= 4.6.0'
gem 'omniauth', '~> 1.9.0'
gem 'omniauth-oauth2'
gem 'omniauth_openid_connect'
gem 'omniauth-rails_csrf_protection', '~> 0.1'
gem 'rolify'
@ -141,4 +142,4 @@ gem 'icalendar'
gem 'tzinfo-data'
# compilation of dynamic stylesheets (home page & theme)
gem 'sassc'
gem 'sassc', '= 2.1.0'

View File

@ -50,6 +50,7 @@ GEM
tzinfo (~> 1.1)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
afm (0.2.2)
ansi (1.5.0)
api-pagination (4.8.2)
@ -57,12 +58,14 @@ GEM
rails (>= 4.1)
arel (9.0.0)
ast (2.4.0)
attr_required (1.0.1)
awesome_print (1.8.0)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
bcrypt (3.1.13)
bindata (2.4.10)
bindex (0.8.1)
bootsnap (1.4.6)
msgpack (~> 1.0)
@ -118,7 +121,6 @@ GEM
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
elasticsearch (5.0.5)
elasticsearch-api (= 5.0.5)
elasticsearch-transport (= 5.0.5)
@ -152,8 +154,8 @@ GEM
forgery (0.7.0)
friendly_id (5.1.0)
activerecord (>= 4.0.0)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
fugit (1.5.3)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
globalid (1.0.0)
activesupport (>= 5.0)
@ -164,11 +166,12 @@ 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)
icalendar (2.7.1)
ice_cube (~> 0.16)
ice_cube (0.16.3)
ice_cube (0.16.4)
ice_nine (0.11.2)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@ -179,6 +182,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 +256,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 +301,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)
@ -350,7 +377,7 @@ GEM
rufus-scheduler (3.8.1)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
sassc (2.4.0)
sassc (2.1.0)
ffi (~> 1.9)
seed_dump (3.3.1)
activerecord (>= 4)
@ -362,16 +389,14 @@ GEM
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (6.4.1)
sidekiq (6.4.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-scheduler (3.1.1)
e2mmap
redis (>= 3, < 5)
sidekiq-scheduler (4.0.0)
redis (>= 4.2.0)
rufus-scheduler (~> 3.2)
sidekiq (>= 3)
thwait
sidekiq (>= 4)
tilt (>= 1.4.0)
sidekiq-unique-jobs (7.1.15)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
@ -396,6 +421,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
@ -403,8 +432,6 @@ GEM
tins (~> 1.0)
thor (1.2.1)
thread_safe (0.3.6)
thwait (0.2.0)
e2mmap
tilt (2.0.10)
tins (1.25.0)
sync
@ -419,6 +446,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 +465,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 +519,7 @@ DEPENDENCIES
omniauth (~> 1.9.0)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 0.1)
omniauth_openid_connect
openlab_ruby
pdf-reader
pg
@ -503,7 +540,7 @@ DEPENDENCIES
rubocop (~> 0.61.1)
rubyXL
rubyzip (>= 1.3.0)
sassc
sassc (= 2.1.0)
seed_dump
sha3
shakapacker (= 6.2.0)

View File

@ -12,6 +12,7 @@ class API::AuthProvidersController < API::ApiController
def create
authorize AuthProvider
@provider = AuthProvider.new(provider_params)
AuthProviderService.auto_configure(@provider)
if @provider.save
render :show, status: :created, location: @provider
else
@ -28,6 +29,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 +85,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__secret client__authorization_endpoint client__token_endpoint
client__userinfo_endpoint client__jwks_uri client__end_session_endpoint profile_url],
auth_provider_mappings_attributes: [:id, :local_model, :local_field, :api_field, :api_endpoint, :api_data_type,
:_destroy, transformation: [:type, :format, :true_value, :false_value,
mapping: %i[from to]]])
end
end
end

View File

@ -3,7 +3,7 @@
# API Controller for resources of type User with role 'member'
class API::MembersController < API::ApiController
before_action :authenticate_user!, except: [:last_subscribed]
before_action :set_member, only: %i[update destroy merge complete_tour update_role]
before_action :set_member, only: %i[update destroy merge complete_tour update_role validate]
respond_to :json
def index
@ -234,6 +234,24 @@ class API::MembersController < API::ApiController
render json: @member
end
def current
@member = current_user
authorize @member
render json: @member
end
def validate
authorize @member
members_service = Members::MembersService.new(@member)
if members_service.validate(user_params[:validated_at].present?)
render :show, status: :ok, location: member_path(@member)
else
render json: @member.errors, status: :unprocessable_entity
end
end
private
def set_member
@ -250,13 +268,14 @@ class API::MembersController < API::ApiController
invoicing_profile_attributes: [
:id,
address_attributes: %i[id address],
organization_attributes: [:id, :name, address_attributes: %i[id address]]
organization_attributes: [:id, :name, address_attributes: %i[id address]],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
],
statistic_profile_attributes: %i[id gender birthday])
elsif current_user.admin? || current_user.manager?
params.require(:user).permit(:username, :email, :password, :password_confirmation, :is_allow_contact, :is_allow_newsletter, :group_id,
tag_ids: [],
:validated_at, tag_ids: [],
profile_attributes: [:id, :first_name, :last_name, :phone, :interest, :software_mastered, :website, :job,
:facebook, :twitter, :google_plus, :viadeo, :linkedin, :instagram, :youtube, :vimeo,
:dailymotion, :github, :echosciences, :pinterest, :lastfm, :flickr,
@ -264,7 +283,8 @@ class API::MembersController < API::ApiController
invoicing_profile_attributes: [
:id,
address_attributes: %i[id address],
organization_attributes: [:id, :name, address_attributes: %i[id address]]
organization_attributes: [:id, :name, address_attributes: %i[id address]],
user_profile_custom_fields_attributes: %i[id value invoicing_profile_id profile_custom_field_id]
],
statistic_profile_attributes: [:id, :gender, :birthday, training_ids: []])

View File

@ -45,6 +45,6 @@ class API::PlanCategoriesController < API::ApiController
end
def plan_category_params
params.require(:plan_category).permit(:name, :weight)
params.require(:plan_category).permit(:name, :weight, :description)
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# API Controller for resources of type ProfileCustomField
# ProfileCustomFields are used to provide admin config user profile custom fields
class API::ProfileCustomFieldsController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_profile_custom_field, only: %i[show update destroy]
def index
@profile_custom_fields = ProfileCustomField.all.order('id ASC')
end
def show; end
def create
authorize ProofOfIdentityType
@profile_custom_field = ProfileCustomField.new(profile_custom_field_params)
if @profile_custom_field.save
render status: :created
else
render json: @profile_custom_field.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @profile_custom_field
if @profile_custom_field.update(profile_custom_field_params)
render status: :ok
else
render json: @pack.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @profile_custom_field
@profile_custom_field.destroy
head :no_content
end
private
def set_profile_custom_field
@profile_custom_field = ProfileCustomField.find(params[:id])
end
def profile_custom_field_params
params.require(:profile_custom_field).permit(:label, :required, :actived)
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityFile
# ProofOfIdentityFiles are used in settings
class API::ProofOfIdentityFilesController < API::ApiController
before_action :authenticate_user!
before_action :set_proof_of_identity_file, only: %i[show update download]
def index
@proof_of_identity_files = ProofOfIdentityFileService.list(current_user, params)
end
# PUT /api/proof_of_identity_files/1/
def update
authorize @proof_of_identity_file
if ProofOfIdentityFileService.update(@proof_of_identity_file, proof_of_identity_file_params)
render :show, status: :ok, location: @proof_of_identity_file
else
render json: @proof_of_identity_file.errors, status: :unprocessable_entity
end
end
# POST /api/proof_of_identity_files/
def create
@proof_of_identity_file = ProofOfIdentityFile.new(proof_of_identity_file_params)
authorize @proof_of_identity_file
if ProofOfIdentityFileService.create(@proof_of_identity_file)
render :show, status: :created, location: @proof_of_identity_file
else
render json: @proof_of_identity_file.errors, status: :unprocessable_entity
end
end
# GET /api/proof_of_identity_files/1/download
def download
authorize @proof_of_identity_file
send_file @proof_of_identity_file.attachment.url, type: @proof_of_identity_file.attachment.content_type, disposition: 'attachment'
end
# GET /api/proof_of_identity_files/1/
def show; end
private
def set_proof_of_identity_file
@proof_of_identity_file = ProofOfIdentityFile.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def proof_of_identity_file_params
params.required(:proof_of_identity_file).permit(:proof_of_identity_type_id, :attachment, :user_id)
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityRefusal
# ProofOfIdentityRefusal are used by admin refuse user's proof of identity file
class API::ProofOfIdentityRefusalsController < API::ApiController
before_action :authenticate_user!
def index
authorize ProofOfIdentityRefusal
@proof_of_identity_files = ProofOfIdentityRefusalService.list(params)
end
def show; end
# POST /api/proof_of_identity_refusals/
def create
authorize ProofOfIdentityRefusal
@proof_of_identity_refusal = ProofOfIdentityRefusal.new(proof_of_identity_refusal_params)
if ProofOfIdentityRefusalService.create(@proof_of_identity_refusal)
render :show, status: :created, location: @proof_of_identity_refusal
else
render json: @proof_of_identity_refusal.errors, status: :unprocessable_entity
end
end
private
# Never trust parameters from the scary internet, only allow the white list through.
def proof_of_identity_refusal_params
params.required(:proof_of_identity_refusal).permit(:message, :operator_id, :user_id, proof_of_identity_type_ids: [])
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
# API Controller for resources of type ProofOfIdentityType
# ProofOfIdentityTypes are used to provide admin config proof of identity type by group
class API::ProofOfIdentityTypesController < API::ApiController
before_action :authenticate_user!, except: :index
before_action :set_proof_of_identity_type, only: %i[show update destroy]
def index
@proof_of_identity_types = ProofOfIdentityTypeService.list(params)
end
def show; end
def create
authorize ProofOfIdentityType
@proof_of_identity_type = ProofOfIdentityType.new(proof_of_identity_type_params)
if @proof_of_identity_type.save
render status: :created
else
render json: @proof_of_identity_type.errors.full_messages, status: :unprocessable_entity
end
end
def update
authorize @proof_of_identity_type
if @proof_of_identity_type.update(proof_of_identity_type_params)
render status: :ok
else
render json: @pack.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
authorize @proof_of_identity_type
@proof_of_identity_type.destroy
head :no_content
end
private
def set_proof_of_identity_type
@proof_of_identity_type = ProofOfIdentityType.find(params[:id])
end
def proof_of_identity_type_params
params.require(:proof_of_identity_type).permit(:name, group_ids: [])
end
end

View File

@ -8,14 +8,7 @@ class API::TrainingsController < API::ApiController
before_action :set_training, only: %i[update destroy]
def index
@requested_attributes = params[:requested_attributes]
@trainings = policy_scope(Training)
@trainings = @trainings.where(public_page: true) if params[:public_page]
return unless attribute_requested?(@requested_attributes, 'availabilities')
@trainings = @trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
.order('availabilities.start_at DESC')
@trainings = TrainingService.list(params)
end
def show

View File

@ -43,6 +43,7 @@ class ApplicationController < ActionController::Base
profile_attributes: %i[phone last_name first_name interest software_mastered],
invoicing_profile_attributes: [
organization_attributes: [:name, address_attributes: [:address]],
user_profile_custom_fields_attributes: %i[profile_custom_field_id value],
address_attributes: [:address]
],
statistic_profile_attributes: %i[gender birthday]

View File

@ -3,11 +3,7 @@
# Devise controller to handle validation of email addresses
class ConfirmationsController < Devise::ConfirmationsController
# The path used after confirmation.
def after_confirmation_path_for(resource_name, resource)
if signed_in?(resource_name)
signed_in_root_path(resource)
else
signed_in_root_path(resource)
end
def after_confirmation_path_for(_resource_name, resource)
signed_in_root_path(resource)
end
end

View File

@ -13,4 +13,9 @@ class PasswordsController < Devise::PasswordsController
head 404
end
end
# POST /password/verify
def verify
current_user.valid_password?(params[:password]) ? head(200) : head(404)
end
end

View File

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

View File

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

View File

@ -0,0 +1 @@
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>github</title><g fill="none" class="nc-icon-wrapper"><path d="M8.633 17.8c0 .082-.095.147-.214.147-.135.013-.23-.053-.23-.147 0-.082.095-.148.214-.148.123-.012.23.053.23.148zm-1.276-.185c-.029.082.053.176.176.2.107.042.23 0 .255-.081.024-.082-.054-.176-.177-.213-.106-.03-.225.012-.254.094zm1.813-.07c-.119.029-.201.107-.189.2.013.083.12.137.242.108.12-.03.201-.107.189-.19-.012-.077-.123-.13-.242-.118zm2.699-15.717c-5.69 0-10.04 4.32-10.04 10.008 0 4.549 2.862 8.44 6.951 9.81.525.095.71-.23.71-.495 0-.255-.012-1.657-.012-2.52 0 0-2.872.616-3.474-1.221 0 0-.468-1.194-1.14-1.501 0 0-.94-.644.065-.632 0 0 1.021.082 1.583 1.058.898 1.584 2.404 1.128 2.99.858.094-.657.361-1.112.656-1.383-2.292-.254-4.606-.586-4.606-4.532 0-1.128.312-1.694.968-2.416-.106-.266-.455-1.366.107-2.785.857-.266 2.83 1.108 2.83 1.108a9.673 9.673 0 0 1 5.152 0s1.972-1.378 2.83-1.108c.562 1.424.213 2.519.106 2.785.657.726 1.058 1.292 1.058 2.416 0 3.958-2.415 4.274-4.708 4.532.377.324.697.94.697 1.903 0 1.383-.012 3.093-.012 3.43 0 .266.189.59.71.496 4.101-1.362 6.882-5.254 6.882-9.803 0-5.69-4.614-10.008-10.303-10.008zM5.815 15.974c-.053.04-.041.136.029.214.065.065.16.094.213.04.053-.04.04-.135-.029-.213-.065-.065-.16-.094-.213-.04v-.001zm-.443-.332c-.029.053.012.12.094.16.066.04.148.029.177-.03.028-.052-.013-.117-.095-.16-.082-.023-.147-.011-.176.03zm1.329 1.46c-.066.054-.041.177.053.255.094.094.213.106.267.04.053-.053.028-.176-.054-.254-.09-.094-.213-.106-.266-.04v-.001zm-.468-.603c-.065.04-.065.148 0 .242.066.095.177.136.23.095.066-.054.066-.16 0-.255-.058-.094-.164-.135-.23-.082z" fill="#F7F7F7"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<defs><style> .nc-icon-wrapper { display: none } .nc-icon-wrapper:target { display: inline } </style></defs>
<svg viewBox="0 0 24 24">
<g id="lastfm" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-4.322 14.667c-2.972 0-4.003-1.34-4.551-3.005-.764-2.39-1.008-3.951-2.954-3.951-1.05 0-2.114.755-2.114 2.869 0 1.65.844 2.68 2.03 2.68 1.34 0 2.231-.998 2.231-.998l.549 1.496s-.928.91-2.869.91c-2.405 0-3.745-1.412-3.745-4.023 0-2.714 1.34-4.312 3.867-4.312 3.445 0 3.787 1.94 4.725 4.776.412 1.257 1.134 2.166 2.869 2.166 1.167 0 1.786-.258 1.786-.895 0-.933-1.022-1.032-2.34-1.34-1.425-.343-1.992-1.084-1.992-2.25 0-1.876 1.514-2.457 3.057-2.457 1.753 0 2.817.637 2.953 2.184l-1.72.206c-.07-.74-.516-1.05-1.341-1.05-.755 0-1.219.343-1.219.929 0 .515.225.825.98.998 1.533.333 3.365.563 3.365 2.695.005 1.72-1.439 2.372-3.567 2.372z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="instagram" class="nc-icon-wrapper">
<path d="M12 9.5a2.5 2.5 0 1 0 .002 5 2.5 2.5 0 0 0-.002-5zm5.846-1.922a2.532 2.532 0 0 0-1.426-1.426c-.984-.388-3.328-.301-4.42-.301s-3.434-.09-4.42.301a2.531 2.531 0 0 0-1.426 1.426c-.388.984-.302 3.33-.302 4.421 0 1.092-.086 3.435.304 4.423a2.531 2.531 0 0 0 1.425 1.425c.984.389 3.328.302 4.42.302 1.094 0 3.434.09 4.421-.302a2.532 2.532 0 0 0 1.426-1.425c.391-.985.301-3.33.301-4.422 0-1.091.09-3.434-.301-4.422h-.002zM12 15.844a3.844 3.844 0 1 1 0-7.689 3.844 3.844 0 0 1 0 7.689zm4.002-6.952a.897.897 0 1 1 .002.002l-.002-.002zM20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-.802 13.594c-.061 1.201-.335 2.266-1.212 3.14-.877.875-1.94 1.155-3.14 1.212-1.239.07-4.95.07-6.188 0-1.202-.06-2.263-.335-3.141-1.212-.878-.876-1.155-1.941-1.212-3.14-.07-1.239-.07-4.95 0-6.188.06-1.201.332-2.266 1.212-3.14.88-.875 1.944-1.152 3.14-1.209 1.239-.07 4.95-.07 6.188 0 1.202.06 2.266.335 3.14 1.212.876.876 1.155 1.941 1.213 3.143.07 1.234.07 4.942 0 6.182z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="github" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C2.50781 1.5 1.5 2.50781 1.5 3.75V20.25C1.5 21.4922 2.50781 22.5 3.75 22.5H20.25C21.4922 22.5 22.5 21.4922 22.5 20.25V3.75C22.5 2.50781 21.4922 1.5 20.25 1.5ZM14.4984 19.4859C14.1047 19.5563 13.9594 19.3125 13.9594 19.1109C13.9594 18.8578 13.9688 17.5641 13.9688 16.5187C13.9688 15.7875 13.725 15.3234 13.4391 15.0797C15.1734 14.8875 17.0016 14.6484 17.0016 11.6531C17.0016 10.8 16.6969 10.3734 16.2 9.825C16.2797 9.62344 16.5469 8.79375 16.1203 7.71563C15.4687 7.51406 13.9781 8.55469 13.9781 8.55469C13.3594 8.38125 12.6891 8.29219 12.0281 8.29219C11.3672 8.29219 10.6969 8.38125 10.0781 8.55469C10.0781 8.55469 8.5875 7.51406 7.93594 7.71563C7.50938 8.78906 7.77188 9.61875 7.85625 9.825C7.35938 10.3734 7.125 10.8 7.125 11.6531C7.125 14.6344 8.87344 14.8875 10.6078 15.0797C10.3828 15.2813 10.1812 15.6281 10.1109 16.125C9.66562 16.3266 8.52656 16.6734 7.84687 15.4734C7.42031 14.7328 6.65156 14.6719 6.65156 14.6719C5.89219 14.6625 6.6 15.15 6.6 15.15C7.10625 15.3844 7.4625 16.2844 7.4625 16.2844C7.91719 17.6766 10.0922 17.2078 10.0922 17.2078C10.0922 17.8594 10.1016 18.9188 10.1016 19.1109C10.1016 19.3125 9.96094 19.5563 9.5625 19.4859C6.46875 18.45 4.30312 15.5062 4.30312 12.0656C4.30312 7.7625 7.59375 4.49531 11.8969 4.49531C16.2 4.49531 19.6875 7.7625 19.6875 12.0656C19.6922 15.5062 17.5922 18.4547 14.4984 19.4859ZM9.9 16.6219C9.81094 16.6406 9.72656 16.6031 9.71719 16.5422C9.70781 16.4719 9.76875 16.4109 9.85781 16.3922C9.94688 16.3828 10.0312 16.4203 10.0406 16.4813C10.0547 16.5422 9.99375 16.6031 9.9 16.6219ZM9.45469 16.5797C9.45469 16.6406 9.38437 16.6922 9.29062 16.6922C9.1875 16.7016 9.11719 16.65 9.11719 16.5797C9.11719 16.5188 9.1875 16.4672 9.28125 16.4672C9.37031 16.4578 9.45469 16.5094 9.45469 16.5797ZM8.8125 16.5281C8.79375 16.5891 8.7 16.6172 8.62031 16.5891C8.53125 16.5703 8.47031 16.5 8.48906 16.4391C8.50781 16.3781 8.60156 16.35 8.68125 16.3688C8.775 16.3969 8.83594 16.4672 8.8125 16.5281ZM8.23594 16.275C8.19375 16.3266 8.10469 16.3172 8.03437 16.2469C7.96406 16.1859 7.94531 16.0969 7.99219 16.0547C8.03437 16.0031 8.12344 16.0125 8.19375 16.0828C8.25469 16.1438 8.27812 16.2375 8.23594 16.275ZM7.80938 15.8484C7.76719 15.8766 7.6875 15.8484 7.63594 15.7781C7.58437 15.7078 7.58437 15.6281 7.63594 15.5953C7.6875 15.5531 7.76719 15.5859 7.80938 15.6562C7.86094 15.7266 7.86094 15.8109 7.80938 15.8484V15.8484ZM7.50469 15.3937C7.4625 15.4359 7.39219 15.4125 7.34062 15.3656C7.28906 15.3047 7.27969 15.2344 7.32187 15.2016C7.36406 15.1594 7.43437 15.1828 7.48594 15.2297C7.5375 15.2906 7.54688 15.3609 7.50469 15.3937ZM7.19063 15.0469C7.17188 15.0891 7.11094 15.0984 7.05937 15.0656C6.99844 15.0375 6.97031 14.9859 6.98906 14.9437C7.00781 14.9156 7.05938 14.9016 7.12031 14.925C7.18125 14.9578 7.20938 15.0094 7.19063 15.0469Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="echosciences" class="nc-icon-wrapper">
<path d="M3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM15.099 5H16.5742L16.6848 5.14204C18.2776 7.18714 19.1861 9.48429 19.1838 11.9303C19.1837 14.433 18.2315 16.7805 16.5685 18.8616L16.4578 19H14.9691L15.4785 18.3944C17.1447 16.4138 18.0564 14.2227 18.0564 11.93C18.0579 9.6857 17.1869 7.54197 15.5936 5.60226L15.099 5ZM4.81616 8.48674V5.73684H13.5809V8.48674H4.81616ZM12.7682 13.3182H7.98311V15.5169H13.5964V18.2632H4.83164V10.5757H12.7682V13.3182Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="flickr" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zM8.273 14.953a2.975 2.975 0 0 1-2.976-2.976A2.975 2.975 0 0 1 8.273 9a2.975 2.975 0 0 1 2.977 2.977 2.975 2.975 0 0 1-2.977 2.976zm7.454 0a2.975 2.975 0 0 1-2.977-2.976A2.975 2.975 0 0 1 15.727 9a2.975 2.975 0 0 1 2.976 2.977 2.975 2.975 0 0 1-2.976 2.976z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="facebook" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75C3.15326 1.5 2.58097 1.73705 2.15901 2.15901C1.73705 2.58097 1.5 3.15326 1.5 3.75L1.5 20.25C1.5 20.8467 1.73705 21.419 2.15901 21.841C2.58097 22.2629 3.15326 22.5 3.75 22.5H10.1836V15.3605H7.23047V12H10.1836V9.43875C10.1836 6.52547 11.918 4.91625 14.5744 4.91625C15.8466 4.91625 17.1769 5.14313 17.1769 5.14313V8.0025H15.7111C14.2669 8.0025 13.8164 8.89875 13.8164 9.81797V12H17.0405L16.5248 15.3605H13.8164V22.5H20.25C20.8467 22.5 21.419 22.2629 21.841 21.841C22.2629 21.419 22.5 20.8467 22.5 20.25V3.75C22.5 3.15326 22.2629 2.58097 21.841 2.15901C21.419 1.73705 20.8467 1.5 20.25 1.5V1.5Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="youtube" class="nc-icon-wrapper">
<path d="M10.2563 9.47344L14.7188 12.0094L10.2563 14.5453V9.47344ZM22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75ZM20.5312 12.0141C20.5312 12.0141 20.5312 9.22031 20.175 7.87969C19.9781 7.13906 19.4016 6.55781 18.6656 6.36094C17.3391 6 12 6 12 6C12 6 6.66094 6 5.33438 6.36094C4.59844 6.55781 4.02187 7.13906 3.825 7.87969C3.46875 9.21563 3.46875 12.0141 3.46875 12.0141C3.46875 12.0141 3.46875 14.8078 3.825 16.1484C4.02187 16.8891 4.59844 17.4469 5.33438 17.6437C6.66094 18 12 18 12 18C12 18 17.3391 18 18.6656 17.6391C19.4016 17.4422 19.9781 16.8844 20.175 16.1437C20.5312 14.8078 20.5312 12.0141 20.5312 12.0141V12.0141Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="vimeo" class="nc-icon-wrapper">
<path d="M20.4 1.5H3.6c-1.158 0-2.1.942-2.1 2.1v16.8c0 1.158.942 2.1 2.1 2.1h16.8c1.158 0 2.1-.942 2.1-2.1V3.6c0-1.158-.942-2.1-2.1-2.1zm-1.228 6.975c-.066 1.477-1.097 3.502-3.094 6.066-2.062 2.68-3.81 4.021-5.236 4.021-.886 0-1.631-.815-2.245-2.451-1.195-4.373-1.706-6.938-2.69-6.938-.113 0-.512.24-1.191.713l-.713-.919C5.752 7.43 7.42 5.723 8.466 5.63c1.18-.113 1.907.693 2.18 2.423.97 6.15 1.4 7.078 3.168 4.294.633-1.003.975-1.767 1.022-2.292.164-1.557-1.214-1.449-2.147-1.05.745-2.443 2.17-3.628 4.275-3.563 1.561.042 2.297 1.055 2.208 3.033z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="viadeo" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-5.592 16.369c-1.988 2.165-5.625 2.184-7.613 0-3.187-3.45-.928-9.192 3.807-9.192.623 0 1.246.098 1.832.314a3.749 3.749 0 0 0-.393 1.27 3.696 3.696 0 0 0-1.44-.281c-2.287 0-3.965 1.954-3.965 4.167 0 2.016 1.336 3.689 3.258 4.026 2.883-1.125 3.417-5.512 3.417-8.203 0-.342 0-.693-.028-1.036-.525-1.542-1.247-3.028-2.072-4.43 1.27.859 1.964 2.93 2.072 4.412v.018a10.15 10.15 0 0 1 .553 3.282c0 2.536-1.027 4.64-3.202 6.009l-.112.01c2.344.046 4.04-1.81 4.04-4.088a4.29 4.29 0 0 0-.323-1.674 3.763 3.763 0 0 0 1.238-.492 5.554 5.554 0 0 1-1.07 5.888zm1.326-6.914c-.623 0-1.176-.333-1.612-.755 1.026-.563 2.325-1.44 2.92-2.484.07-.141.192-.404.211-.563-.586 1.308-2.072 2.335-3.464 2.658a2.092 2.092 0 0 1-.351-1.14c0-.482.243-1.129.604-1.48 1.013-.961 2.485-.399 3.394-2.344 1.523 2.165.614 6.108-1.702 6.108z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="twitter" class="nc-icon-wrapper">
<path d="M20.25 1.5H3.75A2.25 2.25 0 0 0 1.5 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h16.5a2.25 2.25 0 0 0 2.25-2.25V3.75a2.25 2.25 0 0 0-2.25-2.25zm-2.292 7.444c.01.131.01.267.01.398 0 4.064-3.095 8.747-8.748 8.747a8.706 8.706 0 0 1-4.72-1.378c.248.028.487.037.74.037 1.44 0 2.762-.487 3.816-1.312a3.078 3.078 0 0 1-2.873-2.133c.473.07.9.07 1.387-.056a3.075 3.075 0 0 1-2.46-3.019v-.037c.407.23.885.37 1.387.389a3.068 3.068 0 0 1-1.369-2.56c0-.572.15-1.097.417-1.551a8.73 8.73 0 0 0 6.338 3.215c-.436-2.086 1.125-3.778 3-3.778.886 0 1.683.37 2.245.97a6.024 6.024 0 0 0 1.95-.74 3.066 3.066 0 0 1-1.35 1.692A6.117 6.117 0 0 0 19.5 7.35a6.471 6.471 0 0 1-1.542 1.594z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="pinterest" class="nc-icon-wrapper">
<path d="M22.5 3.75V20.25C22.5 21.4922 21.4922 22.5 20.25 22.5H8.7375C9.19687 21.7313 9.7875 20.625 10.0219 19.7203C10.1625 19.1813 10.7391 16.9828 10.7391 16.9828C11.1141 17.7 12.2109 18.3047 13.3781 18.3047C16.8516 18.3047 19.35 15.1125 19.35 11.1469C19.35 7.34531 16.2469 4.5 12.2531 4.5C7.28437 4.5 4.65 7.83281 4.65 11.4656C4.65 13.1531 5.55 15.2531 6.98438 15.9234C7.20469 16.0266 7.31719 15.9797 7.36875 15.7687C7.40625 15.6094 7.60313 14.8266 7.6875 14.4656C7.71563 14.3484 7.70156 14.25 7.60781 14.1375C7.13438 13.5609 6.75 12.5016 6.75 11.5125C6.75 8.97188 8.67187 6.51562 11.9484 6.51562C14.775 6.51562 16.7578 8.44219 16.7578 11.1984C16.7578 14.3109 15.1875 16.4672 13.1391 16.4672C12.0094 16.4672 11.1656 15.5344 11.4328 14.3859C11.7562 13.0172 12.3844 11.5406 12.3844 10.5516C12.3844 8.06719 8.84531 8.40938 8.84531 11.7234C8.84531 12.7406 9.1875 13.4344 9.1875 13.4344C7.71563 19.6594 7.49531 19.7391 7.8 22.4625L7.90313 22.5H3.75C2.50781 22.5 1.5 21.4922 1.5 20.25V3.75C1.5 2.50781 2.50781 1.5 3.75 1.5H20.25C21.4922 1.5 22.5 2.50781 22.5 3.75Z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="linkedin" class="nc-icon-wrapper">
<path d="M21 1.5H2.995C2.17 1.5 1.5 2.18 1.5 3.014v17.972c0 .834.67 1.514 1.495 1.514H21c.825 0 1.5-.68 1.5-1.514V3.014A1.51 1.51 0 0 0 21 1.5zm-13.153 18H4.734V9.478h3.118V19.5h-.005zM6.29 8.11a1.805 1.805 0 0 1 0-3.61c.993 0 1.804.81 1.804 1.805 0 .998-.806 1.804-1.804 1.804zM19.514 19.5h-3.112v-4.875c0-1.162-.024-2.658-1.618-2.658-1.622 0-1.87 1.266-1.87 2.574V19.5H9.802V9.478h2.986v1.369h.042c.417-.788 1.434-1.617 2.948-1.617 3.15 0 3.736 2.076 3.736 4.776V19.5z" fill="currentColor"></path>
</g>
</svg>
<svg viewBox="0 0 24 24">
<g id="dailymotion" class="nc-icon-wrapper">
<path d="M15.512 12.516C15.1636 12.3201 14.7697 12.2197 14.37 12.225C13.75 12.225 13.228 12.432 12.803 12.847C12.378 13.261 12.165 13.783 12.165 14.413C12.165 15.074 12.373 15.615 12.788 16.035C13.203 16.455 13.725 16.665 14.354 16.665C14.995 16.665 15.528 16.45 15.954 16.019C16.379 15.589 16.592 15.053 16.594 14.413C16.5962 14.0277 16.4976 13.6485 16.308 13.313C16.1215 12.9788 15.8459 12.7029 15.512 12.516ZM3.5 1.5C2.39543 1.5 1.5 2.39543 1.5 3.5V20.5C1.5 21.6046 2.39543 22.5 3.5 22.5H20.5C21.6046 22.5 22.5 21.6046 22.5 20.5V3.5C22.5 2.39543 21.6046 1.5 20.5 1.5H3.5ZM19.064 18.997H16.576V17.877H16.544C16.051 18.696 15.179 19.105 13.929 19.105C13.069 19.105 12.304 18.9 11.638 18.491C10.9765 18.0875 10.4416 17.5065 10.094 16.814C9.731 16.105 9.55 15.31 9.55 14.429C9.55 13.567 9.734 12.783 10.102 12.075C10.452 11.385 10.986 10.804 11.645 10.397C12.307 9.988 13.053 9.784 13.882 9.783C14.3579 9.77641 14.8308 9.85952 15.276 10.028C15.691 10.19 16.082 10.456 16.448 10.824V7.172L19.063 6.605L19.065 18.997H19.064Z" fill="currentColor"></path>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,44 @@
import { ActiveProviderResponse, 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;
}
static async active (): Promise<ActiveProviderResponse> {
const res: AxiosResponse<ActiveProviderResponse> = await apiClient.get('/api/auth_providers/active');
return res?.data;
}
}

View File

@ -0,0 +1,23 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { User } from '../models/user';
export default class Authentication {
static async login (email: string, password: string): Promise<User> {
const res: AxiosResponse<User> = await apiClient.post('/users/sign_in.json', { email, password });
return res?.data;
}
static async logout (): Promise<void> {
return apiClient.delete('/users/sign_out.json');
}
static async verifyPassword (password: string): Promise<boolean> {
try {
const res: AxiosResponse<never> = await apiClient.post('/password/verify.json', { password });
return (res.status === 200);
} catch (e) {
return false;
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,47 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { serialize } from 'object-to-formdata';
import { User, UserIndexFilter } from '../models/user';
export default class MemberAPI {
static async list (filters: UserIndexFilter): Promise<Array<User>> {
const res: AxiosResponse<Array<User>> = await apiClient.post('/api/members/list', filters);
return res?.data;
}
static async create (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.post('/api/members', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async update (user: User): Promise<User> {
const data = serialize({ user });
if (user.profile_attributes?.user_avatar_attributes?.attachment_files[0]) {
data.set('user[profile_attributes][user_avatar_attributes][attachment]', user.profile_attributes.user_avatar_attributes.attachment_files[0]);
}
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${user.id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return res?.data;
}
static async current (): Promise<User> {
const res: AxiosResponse<User> = await apiClient.get('/api/members/current');
return res?.data;
}
static async validate (member: User): Promise<User> {
const res: AxiosResponse<User> = await apiClient.patch(`/api/members/${member.id}/validate`, { user: member });
return res?.data;
}
}

View File

@ -0,0 +1,30 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProfileCustomField } from '../models/profile-custom-field';
export default class ProfileCustomFieldAPI {
static async index (): Promise<Array<ProfileCustomField>> {
const res: AxiosResponse<Array<ProfileCustomField>> = await apiClient.get('/api/profile_custom_fields');
return res?.data;
}
static async get (id: number): Promise<ProfileCustomField> {
const res: AxiosResponse<ProfileCustomField> = await apiClient.get(`/api/profile_custom_fields/${id}`);
return res?.data;
}
static async create (profileCustomField: ProfileCustomField): Promise<ProfileCustomField> {
const res: AxiosResponse<ProfileCustomField> = await apiClient.post('/api/profile_custom_fields', { profile_custom_field: profileCustomField });
return res?.data;
}
static async update (profileCustomField: ProfileCustomField): Promise<ProfileCustomField> {
const res: AxiosResponse<ProfileCustomField> = await apiClient.patch(`/api/profile_custom_fields/${profileCustomField.id}`, { profile_custom_field: profileCustomField });
return res?.data;
}
static async destroy (profileCustomFieldId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/profile_custom_fields/${profileCustomFieldId}`);
return res?.data;
}
}

View File

@ -0,0 +1,36 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityFile, ProofOfIdentityFileIndexFilter } from '../models/proof-of-identity-file';
export default class ProofOfIdentityFileAPI {
static async index (filters?: ProofOfIdentityFileIndexFilter): Promise<Array<ProofOfIdentityFile>> {
const res: AxiosResponse<Array<ProofOfIdentityFile>> = await apiClient.get(`/api/proof_of_identity_files${this.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.get(`/api/proof_of_identity_files/${id}`);
return res?.data;
}
static async create (proofOfIdentityFile: FormData): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.post('/api/proof_of_identity_files', proofOfIdentityFile);
return res?.data;
}
static async update (id: number, proofOfIdentityFile: FormData): Promise<ProofOfIdentityFile> {
const res: AxiosResponse<ProofOfIdentityFile> = await apiClient.patch(`/api/proof_of_identity_files/${id}`, proofOfIdentityFile);
return res?.data;
}
static async destroy (proofOfIdentityFileId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/proof_of_identity_files/${proofOfIdentityFileId}`);
return res?.data;
}
private static filtersToQuery (filters?: ProofOfIdentityFileIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -0,0 +1,21 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityRefusal, ProofOfIdentityRefusalIndexFilter } from '../models/proof-of-identity-refusal';
export default class ProofOfIdentityRefusalAPI {
static async index (filters?: ProofOfIdentityRefusalIndexFilter): Promise<Array<ProofOfIdentityRefusal>> {
const res: AxiosResponse<Array<ProofOfIdentityRefusal>> = await apiClient.get(`/api/proof_of_identity_refusals${this.filtersToQuery(filters)}`);
return res?.data;
}
static async create (proofOfIdentityRefusal: ProofOfIdentityRefusal): Promise<ProofOfIdentityRefusal> {
const res: AxiosResponse<ProofOfIdentityRefusal> = await apiClient.post('/api/proof_of_identity_refusals', { proof_of_identity_refusal: proofOfIdentityRefusal });
return res?.data;
}
private static filtersToQuery (filters?: ProofOfIdentityRefusalIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -0,0 +1,36 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ProofOfIdentityType, ProofOfIdentityTypeIndexfilter } from '../models/proof-of-identity-type';
export default class ProofOfIdentityTypeAPI {
static async index (filters?: ProofOfIdentityTypeIndexfilter): Promise<Array<ProofOfIdentityType>> {
const res: AxiosResponse<Array<ProofOfIdentityType>> = await apiClient.get(`/api/proof_of_identity_types${this.filtersToQuery(filters)}`);
return res?.data;
}
static async get (id: number): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.get(`/api/proof_of_identity_types/${id}`);
return res?.data;
}
static async create (proofOfIdentityType: ProofOfIdentityType): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.post('/api/proof_of_identity_types', { proof_of_identity_type: proofOfIdentityType });
return res?.data;
}
static async update (proofOfIdentityType: ProofOfIdentityType): Promise<ProofOfIdentityType> {
const res: AxiosResponse<ProofOfIdentityType> = await apiClient.patch(`/api/proof_of_identity_types/${proofOfIdentityType.id}`, { proof_of_identity_type: proofOfIdentityType });
return res?.data;
}
static async destroy (proofOfIdentityTypeId: number): Promise<void> {
const res: AxiosResponse<void> = await apiClient.delete(`/api/proof_of_identity_types/${proofOfIdentityTypeId}`);
return res?.data;
}
private static filtersToQuery (filters?: ProofOfIdentityTypeIndexfilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -3,7 +3,7 @@ import { AxiosResponse } from 'axios';
import { Space } from '../models/space';
export default class SpaceAPI {
static async index (): Promise<Array<any>> {
static async index (): Promise<Array<Space>> {
const res: AxiosResponse<Array<Space>> = await apiClient.get('/api/spaces');
return res?.data;
}
@ -12,5 +12,4 @@ export default class SpaceAPI {
const res: AxiosResponse<Space> = await apiClient.get(`/api/spaces/${id}`);
return res?.data;
}
}

View File

@ -0,0 +1,10 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Tag } from '../models/tag';
export default class TagAPI {
static async index (): Promise<Array<Tag>> {
const res: AxiosResponse<Array<Tag>> = await apiClient.get('/api/tags');
return res?.data;
}
}

View File

@ -0,0 +1,16 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Training, TrainingIndexFilter } from '../models/training';
export default class TrainingAPI {
static async index (filters?: TrainingIndexFilter): Promise<Array<Training>> {
const res: AxiosResponse<Array<Training>> = await apiClient.get(`/api/trainings${this.filtersToQuery(filters)}`);
return res?.data;
}
private static filtersToQuery (filters?: TrainingIndexFilter): string {
if (!filters) return '';
return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&');
}
}

View File

@ -5,6 +5,7 @@
* creating namespaces and moduled for controllers, filters, services, and directives.
*/
// eslint-disable-next-line no-var -- Application is a global variable.
var Application = Application || {};
Application.Components = angular.module('application.components', []);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,181 @@
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 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 && { 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"
expectedResult="string"
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 }))}
creatable
control={control} />}
<FormSelect id="providable_attributes.prompt"
label={t('app.admin.authentication.openid_connect_form.prompt')}
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_form.prompt_help_html" />}
options={[
{ value: 'none', label: t('app.admin.authentication.openid_connect_form.prompt_none') },
{ value: 'login', label: t('app.admin.authentication.openid_connect_form.prompt_login') },
{ value: 'consent', label: t('app.admin.authentication.openid_connect_form.prompt_consent') },
{ value: 'select_account', label: t('app.admin.authentication.openid_connect_form.prompt_select_account') }
]}
clearable
control={control} />
<FormSelect id="providable_attributes.send_scope_to_token_endpoint"
label={t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_help')}
options={[
{ value: false, label: t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_false') },
{ value: true, label: t('app.admin.authentication.openid_connect_form.send_scope_to_token_endpoint_true') }
]}
valueDefault={true}
control={control} />
<FormInput id="providable_attributes.profile_url"
register={register}
placeholder="https://sso.exemple.com/my-account"
label={t('app.admin.authentication.openid_connect_form.profile_edition_url')}
tooltip={t('app.admin.authentication.openid_connect_form.profile_edition_url_help')}
rules={{ pattern: urlRegex }} />
<h4>{t('app.admin.authentication.openid_connect_form.client_options')}</h4>
<FormInput id="providable_attributes.client__identifier"
label={t('app.admin.authentication.openid_connect_form.client__identifier')}
rules={{ required: true }}
register={register} />
<FormInput id="providable_attributes.client__secret"
label={t('app.admin.authentication.openid_connect_form.client__secret')}
rules={{ required: true }}
register={register} />
{!currentFormValues?.discovery && <div className="client-options-without-discovery">
<FormInput id="providable_attributes.client__authorization_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__authorization_endpoint')}
placeholder="/authorize"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
<FormInput id="providable_attributes.client__token_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__token_endpoint')}
placeholder="/token"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
<FormInput id="providable_attributes.client__userinfo_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__userinfo_endpoint')}
placeholder="/userinfo"
rules={{ required: !currentFormValues?.discovery, pattern: endpointRegex }}
register={register} />
{currentFormValues?.client_auth_method === 'jwks' && <FormInput id="providable_attributes.client__jwks_uri"
label={t('app.admin.authentication.openid_connect_form.client__jwks_uri')}
rules={{ required: currentFormValues.client_auth_method === 'jwks', pattern: endpointRegex }}
placeholder="/jwk"
register={register} />}
<FormInput id="providable_attributes.client__end_session_endpoint"
label={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint')}
tooltip={t('app.admin.authentication.openid_connect_form.client__end_session_endpoint_help')}
rules={{ pattern: endpointRegex }}
register={register} />
</div>}
</div>
);
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
}
/**
* This component will catch javascript errors anywhere in their child component tree and display a message to the user.
* @see https://reactjs.org/docs/error-boundaries.html
*/
export class ErrorBoundary extends React.Component<unknown, ErrorBoundaryState> {
constructor (props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError () {
return { hasError: true };
}
componentDidCatch (error, errorInfo) {
console.error(error, errorInfo);
}
render () {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}

View File

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

View File

@ -1,9 +1,7 @@
import React, { ReactNode, BaseSyntheticEvent, useEffect, useState } from 'react';
import React, { ReactNode, BaseSyntheticEvent, useEffect } from 'react';
import Modal from 'react-modal';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import CustomAssetAPI from '../../api/custom-asset';
import { CustomAsset, CustomAssetName } from '../../models/custom-asset';
import { FabButton } from './fab-button';
Modal.setAppElement('body');
@ -36,79 +34,32 @@ interface FabModalProps {
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customHeader, customFooter, onConfirm, preventConfirm, onCreation, onConfirmSendFormId }) => {
const { t } = useTranslation('shared');
const [blackLogo, setBlackLogo] = useState<CustomAsset>(null);
// initial request to the API to get the theme's logo, for back backgrounds
useEffect(() => {
CustomAssetAPI.get(CustomAssetName.LogoBlackFile).then(data => setBlackLogo(data));
}, []);
useEffect(() => {
if (typeof onCreation === 'function' && isOpen) {
onCreation();
}
}, [isOpen]);
/**
* Check if the confirm button should be present
*/
const hasConfirmButton = (): boolean => {
return confirmButton !== undefined;
};
/**
* Check if the behavior of the confirm button is to send a form, using the provided ID
*/
const confirmationSendForm = (): boolean => {
return onConfirmSendFormId !== undefined;
};
/**
* Should we display the close button?
*/
const hasCloseButton = (): boolean => {
return closeButton;
};
/**
* Check if there's a custom footer
*/
const hasCustomFooter = (): boolean => {
return customFooter !== undefined;
};
/**
* Check if there's a custom header
*/
const hasCustomHeader = (): boolean => {
return customHeader !== undefined;
};
return (
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
{closeButton && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
<div className="fab-modal-header">
<Loader>
{blackLogo && <img src={blackLogo.custom_asset_file_attributes.attachment_url}
alt={blackLogo.custom_asset_file_attributes.attachment}
className="modal-logo" />}
</Loader>
{!hasCustomHeader() && <h1>{ title }</h1>}
{hasCustomHeader() && customHeader}
{!customHeader && <h1>{ title }</h1>}
{customHeader && customHeader}
</div>
<div className="fab-modal-content">
{children}
</div>
<div className="fab-modal-footer">
{(customFooter || confirmButton) && <div className="fab-modal-footer">
<Loader>
{hasCloseButton() && <FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
{hasConfirmButton() && !confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasConfirmButton() && confirmationSendForm() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} type="submit" form={onConfirmSendFormId}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
{confirmButton && !onConfirmSendFormId && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{confirmButton && onConfirmSendFormId && <FabButton className="modal-btn--confirm" disabled={preventConfirm} type="submit" form={onConfirmSendFormId}>{confirmButton}</FabButton>}
{customFooter && customFooter}
</Loader>
</div>
</div>}
</Modal>
);
};

View File

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

View File

@ -0,0 +1,104 @@
import React, { forwardRef, RefObject, useEffect, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Iframe from './iframe';
import Image from '@tiptap/extension-image';
import { MenuBar } from './menu-bar';
import { WarningOctagon } from 'phosphor-react';
interface FabTextEditorProps {
paragraphTools?: boolean,
content?: string,
limit?: number,
video?: boolean,
image?: boolean,
onChange?: (content: string) => void,
placeholder?: string,
error?: string,
disabled?: boolean
}
export interface FabTextEditorRef {
focus: () => void
}
/**
* This component is a WYSIWYG text editor
*/
export const FabTextEditor: React.ForwardRefRenderFunction<FabTextEditorRef, FabTextEditorProps> = ({ paragraphTools, content, limit = 400, video, image, onChange, placeholder, error, disabled = false }, ref: RefObject<FabTextEditorRef>) => {
const { t } = useTranslation('shared');
const placeholderText = placeholder || t('app.shared.text_editor.text_placeholder');
// TODO: Add ctrl+click on link to visit
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
// the methods in useImperativeHandle are exposed to the parent component
useImperativeHandle(ref, () => ({
focus () {
editorRef.current?.commands?.focus();
}
}), []);
// Setup the editor
// Extensions add functionalities to the editor (Bold, Italic…)
// Events fire action (onUpdate -> get the content as HTML)
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [3]
}
}),
Underline,
Link.configure({
openOnClick: false
}),
Placeholder.configure({
placeholder: placeholderText
}),
CharacterCount.configure({
limit
}),
Iframe,
Image.configure({
HTMLAttributes: {
class: 'fab-textEditor-image'
}
})
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
}
});
useEffect(() => {
editor?.setEditable(!disabled);
}, [disabled]);
// bind the editor to the ref, once it is ready
if (!editor) return null;
editorRef.current = editor;
return (
<div className={`fab-textEditor ${disabled && 'is-disabled'}`}>
<MenuBar editor={editor} paragraphTools={paragraphTools} video={video} image={image} disabled={disabled} />
<EditorContent editor={editor} />
<div className="fab-textEditor-character-count">
{editor?.storage.characterCount.characters()} / {limit}
</div>
{error &&
<div className="fab-textEditor-error">
<WarningOctagon size={24} />
<p className="">{error}</p>
</div>
}
</div>
);
};
export default forwardRef(FabTextEditor);

View File

@ -0,0 +1,76 @@
import { Node } from '@tiptap/core';
export interface IframeOptions {
allowFullscreen: boolean,
HTMLAttributes: {
[key: string]: string
},
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
/**
* Add an iframe to embed a video
*/
setIframe: (options: { src: string }) => ReturnType,
}
}
}
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions () {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'fab-textEditor-video'
}
};
},
addAttributes () {
return {
src: {
default: null
},
frameborder: {
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
},
parseHTML () {
return [{
tag: 'iframe'
}];
},
renderHTML ({ HTMLAttributes }) {
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
},
addCommands () {
return {
setIframe: (options: { src: string }) => ({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
}
};
}
});

View File

@ -0,0 +1,283 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import useOnclickOutside from 'react-cool-onclickoutside';
import { Editor } from '@tiptap/react';
import { TextAa, TextBolder, TextItalic, TextUnderline, LinkSimpleHorizontal, ListBullets, Quotes, Trash, CheckCircle, VideoCamera, Image } from 'phosphor-react';
interface MenuBarProps {
editor?: Editor,
paragraphTools?: boolean,
video?: boolean,
image?: boolean,
disabled?: boolean,
}
/**
* This component is the menu bar for the WYSIWYG text editor
*/
export const MenuBar: React.FC<MenuBarProps> = ({ editor, paragraphTools, video, image, disabled = false }) => {
const { t } = useTranslation('shared');
const [submenu, setSubmenu] = useState('');
const resetUrl = { href: '', target: '_blank' };
const [url, setUrl] = useState(resetUrl);
const [videoProvider, setVideoProvider] = useState('youtube');
const [videoId, setVideoId] = useState('');
const [imageUrl, setImageUrl] = useState('');
// Reset state values when the submenu is closed
useEffect(() => {
if (!submenu) {
setUrl(resetUrl);
setVideoProvider('youtube');
setImageUrl('');
}
}, [submenu]);
// Close the submenu frame on click outside
const ref = useOnclickOutside(() => {
setSubmenu('');
});
// Toggle submenu's visibility
const toggleSubmenu = (type) => {
if (submenu !== type) {
setSubmenu(type);
if (type === 'link') {
const previousUrl = {
href: editor.getAttributes('link').href,
target: editor.getAttributes('link').target || ''
};
// display selected text's attributes if it's a link
if (previousUrl.href) {
setUrl(previousUrl);
}
}
} else {
setSubmenu('');
}
};
// Set link's target
const toggleTarget = (evt) => {
evt.target.checked
? setUrl({ href: url.href, target: '_blank' })
: setUrl({ href: url.href, target: '' });
};
// Update url
const linkUrlChange = (evt) => {
setUrl({ ...url, href: evt.target.value });
};
// Support keyboard "Enter" key event to validate
const handleEnter = (evt) => {
if (evt.keyCode === 13) {
setLink();
}
};
// Update the selected link
const setLink = useCallback((closeLinkMenu?: boolean) => {
if (url.href === '') {
unsetLink();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url.href, target: url.target }).run();
if (closeLinkMenu) {
setSubmenu('');
}
}, [editor, url]);
// Remove the link tag from the selected text
const unsetLink = () => {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
setSubmenu('');
};
// Store selected video provider in state
const handleSelect = (evt) => {
setVideoProvider(evt.target.value);
};
// Store video id in state
const videoUrlChange = (evt) => {
const id = evt.target.value.match(/([^/]+$)/g);
setVideoId(id);
};
// Insert iframe containing the video player
const addIframe = () => {
let videoUrl = '';
switch (videoProvider) {
case 'youtube':
videoUrl = `https://www.youtube.com/embed/${videoId}`;
break;
case 'vimeo':
videoUrl = `https://player.vimeo.com/video/${videoId}`;
break;
case 'dailymotion':
videoUrl = `https://www.dailymotion.com/embed/video/${videoId}`;
break;
default:
break;
}
editor.chain().focus().setIframe({ src: videoUrl }).run();
setSubmenu('');
};
// Store image url in state
const imageUrlChange = (evt) => {
setImageUrl(evt.target.value);
};
// Insert image
const addImage = () => {
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run();
setSubmenu('');
}
};
if (!editor) {
return null;
}
return (
<>
<div className={`fab-textEditor-menu ${disabled ? 'fab-textEditor-menu--disabled' : ''}`}>
{ paragraphTools &&
(<>
<button
type='button'
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
disabled={disabled}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
>
<TextAa size={24} />
</button>
<button
type='button'
onClick={() => editor.chain().focus().toggleBulletList().run()}
disabled={disabled}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
<ListBullets size={24} />
</button>
<button
type='button'
onClick={() => editor.chain().focus().toggleBlockquote().run()}
disabled={disabled}
className={editor.isActive('blockquote') ? 'is-active' : ''}
>
<Quotes size={24} />
</button>
<span className='divider'></span>
</>)
}
<button
type='button'
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={disabled}
className={editor.isActive('bold') ? 'is-active' : ''}
>
<TextBolder size={24} />
</button>
<button
type='button'
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={disabled}
className={editor.isActive('italic') ? 'is-active' : ''}
>
<TextItalic size={24} />
</button>
<button
type='button'
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={disabled}
className={editor.isActive('underline') ? 'is-active' : ''}
>
<TextUnderline size={24} />
</button>
<button
type='button'
onClick={() => toggleSubmenu('link')}
disabled={disabled}
className={`ignore-onclickoutside ${editor.isActive('link') ? 'is-active' : ''}`}
>
<LinkSimpleHorizontal size={24} />
</button>
{ (video || image) && <span className='divider'></span> }
{ video &&
(<>
<button
type='button'
disabled={disabled}
onClick={() => toggleSubmenu('video')}
>
<VideoCamera size={24} />
</button>
</>)
}
{ image &&
(<>
<button
type='button'
disabled={disabled}
onClick={() => toggleSubmenu('image')}
>
<Image size={24} />
</button>
</>)
}
</div>
<div ref={ref} className={`fab-textEditor-subMenu ${submenu ? 'is-active' : ''}`}>
{ submenu === 'link' &&
(<>
<h6>{t('app.shared.text_editor.add_link')}</h6>
<div>
<input value={url.href} onChange={linkUrlChange} onKeyDown={handleEnter} type="text" placeholder={t('app.shared.text_editor.link_placeholder')} />
<button type='button' onClick={unsetLink}>
<Trash size={24} />
</button>
</div>
<div>
<label className='tab'>
<p>{t('app.shared.text_editor.new_tab')}</p>
<input type="checkbox" onChange={toggleTarget} checked={url.target === '_blank'} />
<span className='switch'></span>
</label>
<button type='button' onClick={() => setLink(true)}>
<CheckCircle size={24} />
</button>
</div>
</>)
}
{ submenu === 'video' &&
(<>
<h6>{t('app.shared.text_editor.add_video')}</h6>
<select name="provider" onChange={handleSelect}>
<option value="youtube">YouTube</option>
<option value="vimeo">Vimeo</option>
<option value="dailymotion">Dailymotion</option>
</select>
<div>
<input type="text" onChange={videoUrlChange} placeholder={t('app.shared.text_editor.url_placeholder')} />
<button type='button' onClick={() => addIframe()}>
<CheckCircle size={24} />
</button>
</div>
</>)
}
{ submenu === 'image' &&
(<>
<h6>{t('app.shared.text_editor.add_image')}</h6>
<div>
<input type="text" onChange={imageUrlChange} placeholder={t('app.shared.text_editor.url_placeholder')} />
<button type='button' onClick={() => addImage()}>
<CheckCircle size={24} />
</button>
</div>
</>)
}
</div>
</>
);
};

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { LabelledInput } from './base/labelled-input';
import { useTranslation } from 'react-i18next';
import { TDateISODate } from '../typings/date-iso';
interface DocumentFiltersProps {
onFilterChange: (value: { reference: string, customer: string, date: Date }) => void
onFilterChange: (value: { reference: string, customer: string, date: TDateISODate }) => void
}
/**
@ -13,11 +14,11 @@ export const DocumentFilters: React.FC<DocumentFiltersProps> = ({ onFilterChange
const { t } = useTranslation('admin');
// stores the value of reference input
const [referenceFilter, setReferenceFilter] = useState('');
const [referenceFilter, setReferenceFilter] = useState<string>('');
// stores the value of the customer input
const [customerFilter, setCustomerFilter] = useState('');
const [customerFilter, setCustomerFilter] = useState<string>('');
// stores the value of the date input
const [dateFilter, setDateFilter] = useState(null);
const [dateFilter, setDateFilter] = useState<TDateISODate>(null);
/**
* When any filter changes, trigger the callback with the current value of all filters

View File

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

View File

@ -0,0 +1,77 @@
import React, { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import { AbstractFormComponent } from '../../models/form-component';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { get as _get } from 'lodash';
export interface AbstractFormItemProps<TFieldValues> extends PropsWithChildren<AbstractFormComponent<TFieldValues>> {
id: string,
label?: string|ReactNode,
tooltip?: ReactNode,
className?: string,
disabled?: boolean|((id: string) => boolean),
onLabelClick?: (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) => void,
}
/**
* This abstract component should not be used directly.
* Other forms components that are intended to be used with react-hook-form must extend this component.
*/
export const AbstractFormItem = <TFieldValues extends FieldValues>({ id, label, tooltip, className, disabled, error, warning, rules, formState, onLabelClick, children }: AbstractFormItemProps<TFieldValues>) => {
const [isDirty, setIsDirty] = useState<boolean>(false);
const [fieldError, setFieldError] = useState<{ message: string }>(error);
const [isDisabled, setIsDisabled] = useState<boolean>(false);
useEffect(() => {
setIsDirty(_get(formState?.dirtyFields, id));
setFieldError(_get(formState?.errors, id));
}, [formState]);
useEffect(() => {
setFieldError(error);
}, [error]);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
// Compose classnames from props
const classNames = [
'form-item',
`${className || ''}`,
`${isDirty && fieldError ? 'is-incorrect' : ''}`,
`${isDirty && warning ? 'is-warned' : ''}`,
`${rules && rules.required ? 'is-required' : ''}`,
`${isDisabled ? 'is-disabled' : ''}`
].join(' ');
/**
* This function is called when the label is clicked.
* It is used to focus the input.
*/
function handleLabelClick (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) {
if (typeof onLabelClick === 'function') {
onLabelClick(event);
}
}
return (
<label className={classNames} onClick={handleLabelClick}>
{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'>
{children}
</div>
{(isDirty && fieldError) && <div className="form-item-error">{fieldError.message}</div> }
{(isDirty && warning) && <div className="form-item-warning">{warning.message}</div> }
</label>
);
};

View File

@ -0,0 +1,74 @@
import React, { ReactNode, useCallback } from 'react';
import { FieldPathValue } from 'react-hook-form';
import { debounce as _debounce } 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';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormInputProps<TFieldValues, TInputType> extends FormComponent<TFieldValues>, AbstractFormItemProps<TFieldValues> {
icon?: ReactNode,
addOn?: ReactNode,
addOnAction?: (event: React.MouseEvent<HTMLButtonElement>) => void,
addOnClassName?: string,
debounce?: number,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week' | 'hidden' | 'file',
accept?: string,
defaultValue?: TInputType,
placeholder?: string,
step?: number | 'any',
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void,
}
/**
* This component is a template for an input component to use within React Hook Form
*/
export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, register, label, tooltip, defaultValue, icon, className, rules, disabled, type, addOn, addOnAction, addOnClassName, placeholder, error, warning, formState, step, onChange, debounce, accept }: FormInputProps<TFieldValues, TInputType>) => {
/**
* 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',
`${className || ''}`,
`${type === 'hidden' ? 'is-hidden' : ''}`
].join(' ');
return (
<AbstractFormItem id={id} formState={formState} label={label}
className={classNames} tooltip={tooltip}
disabled={disabled}
rules={rules} error={error} warning={warning}>
{icon && <span className="icon">{icon}</span>}
<input id={id}
{...register(id as FieldPath<TFieldValues>, {
...rules,
valueAsNumber: type === 'number',
valueAsDate: type === 'date',
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
onChange: (e) => { handleChange(e); }
})}
type={type}
step={step}
disabled={typeof disabled === 'function' ? disabled(id) : disabled}
placeholder={placeholder}
accept={accept} />
{addOn && <span onClick={addOnAction} className={`addon ${addOnClassName || ''} ${addOnAction ? 'is-btn' : ''}`}>{addOn}</span>}
</AbstractFormItem>
);
};

View File

@ -0,0 +1,126 @@
import React, { useEffect } 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';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import CreatableSelect from 'react-select/creatable';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
options: Array<selectOption<TOptionValue>>,
valuesDefault?: Array<TOptionValue>,
onChange?: (values: Array<TOptionValue>) => void,
placeholder?: string,
expectedResult?: 'array' | 'string'
creatable?: 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, formState, warning, expectedResult, creatable }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
const [allOptions, setAllOptions] = React.useState<Array<selectOption<TOptionValue>>>(options);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
useEffect(() => {
setAllOptions(options);
}, [options]);
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
* when the selected option changes.
* It will also update the react-hook-form's value, according to the provided 'result' property (string or array).
*/
const onChangeCb = (newValues: Array<TOptionValue>, rhfOnChange): void => {
if (typeof onChange === 'function') {
onChange(newValues);
}
if (expectedResult === 'string') {
rhfOnChange(newValues.join(','));
} else {
rhfOnChange(newValues);
}
};
/**
* This function will return the currently selected options, according to the provided react-hook-form's value.
*/
const getCurrentValues = (value: Array<TOptionValue>|string): Array<selectOption<TOptionValue>> => {
let values: Array<TOptionValue> = [];
if (typeof value === 'string') {
values = value.split(',') as Array<unknown> as Array<TOptionValue>;
} else {
values = value;
}
return allOptions.filter(c => values?.includes(c.value));
};
/**
* When the select is 'creatable', this callback handle the creation and the selection of a new option.
*/
const handleCreate = (value: Array<TOptionValue>|string, rhfOnChange) => {
return (inputValue: string) => {
// add the new value to the list of options
const newOption = { value: inputValue as unknown as TOptionValue, label: inputValue };
setAllOptions([...allOptions, newOption]);
// select the new option
const values = getCurrentValues(value);
values.push(newOption);
onChangeCb(values.map(c => c.value), rhfOnChange);
};
};
// if the user can create new options, we need to use a different component
const AbstractSelect = creatable ? CreatableSelect : Select;
return (
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-multi-select ${className || ''}`} tooltip={tooltip}
disabled={disabled}
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valuesDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={getCurrentValues(value)}
onChange={val => {
const values = val?.map(c => c.value);
onChangeCb(values, onChange);
}}
onCreateOption={handleCreate(value, onChange)}
placeholder={placeholder}
options={allOptions}
isDisabled={isDisabled}
isMulti />
} />
</AbstractFormItem>
);
};
FormMultiSelect.defaultProps = {
expectedResult: 'array',
creatable: false,
disabled: false
};

View File

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { FormControlledComponent } from '../../models/form-component';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import FabTextEditor, { FabTextEditorRef } from '../base/text-editor/fab-text-editor';
import { Controller, Path } from 'react-hook-form';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
interface FormRichTextProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
valueDefault?: string,
limit?: number,
paragraphTools?: boolean,
video?: boolean,
image?: boolean,
}
/**
* This component is a rich-text editor to use with react-hook-form.
*/
export const FormRichText = <TFieldValues extends FieldValues, TContext extends object>({ id, label, tooltip, className, control, valueDefault, error, warning, rules, disabled = false, formState, limit, paragraphTools, video, image }: FormRichTextProps<TFieldValues, TContext>) => {
const textEditorRef = React.useRef<FabTextEditorRef>();
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
/**
* Callback triggered when the user clicks to get the focus on the editor.
* We do not want the default behavior (focus the first child, which is the Bold button)
* but we want to focus the text edition area.
*/
function focusTextEditor (event: React.MouseEvent<HTMLLabelElement, MouseEvent>) {
event.preventDefault();
textEditorRef.current.focus();
}
return (
<AbstractFormItem id={id} label={label} tooltip={tooltip}
className={`form-rich-text ${className || ''}`}
error={error} warning={warning} rules={rules}
disabled={disabled} formState={formState} onLabelClick={focusTextEditor}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value } }) =>
<FabTextEditor onChange={onChange}
content={value}
limit={limit}
paragraphTools={paragraphTools}
video={video}
image={image}
disabled={isDisabled}
ref={textEditorRef} />
} />
</AbstractFormItem>
);
};

View File

@ -0,0 +1,78 @@
import React, { useEffect } from 'react';
import Select from 'react-select';
import CreatableSelect from 'react-select/creatable';
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';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormSelectProps<TFieldValues, TContext extends object, TOptionValue> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
options: Array<selectOption<TOptionValue>>,
valueDefault?: TOptionValue,
onChange?: (value: TOptionValue) => void,
placeholder?: string,
clearable?: boolean,
creatable?: 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, warning, rules, disabled = false, onChange, clearable = false, formState, creatable = false }: FormSelectProps<TFieldValues, TContext, TOptionValue>) => {
const [isDisabled, setIsDisabled] = React.useState<boolean>(false);
useEffect(() => {
if (typeof disabled === 'function') {
setIsDisabled(disabled(id));
} else {
setIsDisabled(disabled);
}
}, [disabled]);
/**
* 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);
}
};
// if the user can create new options, we need to use a different component
const AbstractSelect = creatable ? CreatableSelect : Select;
return (
<AbstractFormItem id={id} label={label} tooltip={tooltip}
className={`form-select ${className || ''}`} formState={formState}
error={error} warning={warning} rules={rules}
disabled={disabled}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={valueDefault as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
}}
placeholder={placeholder}
isDisabled={isDisabled}
isClearable={clearable}
options={options} />
} />
</AbstractFormItem>
);
};

View File

@ -0,0 +1,50 @@
import React from 'react';
import { FormControlledComponent } from '../../models/form-component';
import { FieldPath } from 'react-hook-form/dist/types/path';
import { FieldPathValue, UnpackNestedValue } from 'react-hook-form/dist/types';
import { Controller, Path } from 'react-hook-form';
import Switch from 'react-switch';
import { AbstractFormItem, AbstractFormItemProps } from './abstract-form-item';
interface FormSwitchProps<TFieldValues, TContext extends object> extends FormControlledComponent<TFieldValues, TContext>, AbstractFormItemProps<TFieldValues> {
defaultValue?: boolean,
onChange?: (value: boolean) => void,
}
/**
* This component is a wrapper for react-switch, to use with react-hook-form.
*/
export const FormSwitch = <TFieldValues, TContext extends object>({ id, label, tooltip, className, error, rules, disabled, control, defaultValue, formState, warning, onChange }: FormSwitchProps<TFieldValues, TContext>) => {
/**
* The following callback will trigger the onChange callback, if it was passed to this component,
* when the selected option changes.
*/
const onChangeCb = (newValue: boolean): void => {
if (typeof onChange === 'function') {
onChange(newValue);
}
};
return (
<AbstractFormItem id={id} formState={formState} label={label}
className={`form-switch ${className || ''}`} tooltip={tooltip}
disabled={disabled}
rules={rules} error={error} warning={warning}>
<Controller name={id as FieldPath<TFieldValues>}
control={control}
defaultValue={defaultValue as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>}
rules={rules}
render={({ field: { onChange, value, ref } }) =>
<Switch onChange={val => {
onChange(val);
onChangeCb(val);
}}
checked={value as boolean || false}
height={19}
width={40}
ref={ref}
disabled={typeof disabled === 'function' ? disabled(id) : disabled} />
} />
</AbstractFormItem>
);
};

View File

@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react';
import { User } from '../../models/user';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import { Group } from '../../models/group';
import GroupAPI from '../../api/group';
import { FabButton } from '../base/fab-button';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { FormSelect } from '../form/form-select';
import MemberAPI from '../../api/member';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import UserLib from '../../lib/user';
declare const Application: IApplication;
interface ChangeGroupProps {
user: User,
onSuccess: (message: string, user: User) => void,
onError: (message: string) => void,
allowChange?: boolean,
className?: string,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
export const ChangeGroup: React.FC<ChangeGroupProps> = ({ user, onSuccess, onError, allowChange, className }) => {
const { t } = useTranslation('shared');
const [groups, setGroups] = useState<Array<Group>>([]);
const [changeRequested, setChangeRequested] = useState<boolean>(false);
const [operator, setOperator] = useState<User>(null);
const [allowedUserChangeGoup, setAllowedUserChangeGoup] = useState<boolean>(false);
const { handleSubmit, control } = useForm();
useEffect(() => {
GroupAPI.index({ disabled: false, admins: false }).then(setGroups).catch(onError);
MemberAPI.current().then(setOperator).catch(onError);
SettingAPI.get(SettingName.UserChangeGroup).then((setting) => {
setAllowedUserChangeGoup(setting.value === 'true');
}).catch(onError);
}, []);
useEffect(() => {
setChangeRequested(false);
}, [user, allowChange]);
/**
* Displays or hide the form to change the group.
*/
const toggleChangeRequest = () => {
setChangeRequested(!changeRequested);
};
/**
* Check if the group changing is currently allowed.
*/
const canChangeGroup = (): boolean => {
const userLib = new UserLib(operator);
return allowChange && (allowedUserChangeGoup || userLib.isPrivileged(user));
};
/**
* Convert the provided array of items to the react-select format
*/
const buildGroupsOptions = (): Array<selectOption> => {
return groups?.map(t => {
return { value: t.id, label: t.name };
});
};
/**
* Callback triggered when the group changing form is submitted.
*/
const onSubmit = (data: { group_id: number }) => {
MemberAPI.update({ id: user.id, group_id: data.group_id } as User).then(res => {
toggleChangeRequest();
onSuccess(t('app.shared.change_group.success'), res);
}).catch(onError);
};
// do not render the component if no user were provided (we cannot change th group of nobody)
if (!user) return null;
return (
<div className={`change-group ${className || ''}`}>
<h3>{t('app.shared.change_group.title', { OPERATOR: operator?.id === user.id ? 'self' : 'admin' })}</h3>
{!changeRequested && <div className="display">
<div className="current-group">
{groups.find(group => group.id === user.group_id)?.name}
</div>
{canChangeGroup() && <FabButton className="request-change-btn" onClick={toggleChangeRequest}>
{t('app.shared.change_group.change', { OPERATOR: operator?.id === user.id ? 'self' : 'admin' })}
</FabButton>}
</div>}
{changeRequested && <form className="change-group-form" onSubmit={handleSubmit(onSubmit)}>
<FormSelect options={buildGroupsOptions()} control={control} id="group_id" valueDefault={user.group_id} />
<div className="actions">
<FabButton className="cancel-btn" onClick={toggleChangeRequest}>{t('app.shared.change_group.cancel')}</FabButton>
<FabButton type="submit" className="validate-btn">{t('app.shared.change_group.validate')}</FabButton>
</div>
</form>}
</div>
);
};
ChangeGroup.defaultProps = {
allowChange: true
};
const ChangeGroupWrapper: React.FC<ChangeGroupProps> = (props) => {
return (
<Loader>
<ChangeGroup {...props} />
</Loader>
);
};
Application.Components.component('changeGroup', react2angular(ChangeGroupWrapper, ['user', 'onSuccess', 'onError', 'allowChange', 'className']));

View File

@ -14,13 +14,14 @@ interface MachineCardProps {
onEnrollRequested: (trainingId: number) => void,
onError: (message: string) => void,
onSuccess: (message: string) => void,
canProposePacks: boolean,
}
/**
* This component is a box showing the picture of the given machine and two buttons: one to start the reservation process
* and another to redirect the user to the machine description page.
*/
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested, canProposePacks }) => {
const { t } = useTranslation('public');
// shall we display a loader to prevent double-clicking, while the machine details are loading?
@ -65,6 +66,7 @@ const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onSho
onReserveMachine={handleReserveMachine}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested}
canProposePacks={canProposePacks}
className="reserve-button">
<i className="fas fa-bookmark" />
{t('app.public.machine_card.book')}
@ -80,10 +82,10 @@ const MachineCardComponent: React.FC<MachineCardProps> = ({ user, machine, onSho
);
};
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested }) => {
export const MachineCard: React.FC<MachineCardProps> = ({ user, machine, onShowMachine, onReserveMachine, onError, onSuccess, onLoginRequested, onEnrollRequested, canProposePacks }) => {
return (
<Loader>
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onSuccess={onSuccess} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
<MachineCardComponent user={user} machine={machine} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onError={onError} onSuccess={onSuccess} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks} />
</Loader>
);
};

View File

@ -18,12 +18,13 @@ interface MachinesListProps {
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
canProposePacks: boolean,
}
/**
* This component shows a list of all machines and allows filtering on that list.
*/
const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user }) => {
const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, user, canProposePacks }) => {
// shown machines
const [machines, setMachines] = useState<Array<Machine>>(null);
// we keep the full list of machines, for filtering
@ -68,19 +69,20 @@ const MachinesList: React.FC<MachinesListProps> = ({ onError, onSuccess, onShowM
onError={onError}
onSuccess={onSuccess}
onLoginRequested={onLoginRequested}
onEnrollRequested={onEnrollRequested} />;
onEnrollRequested={onEnrollRequested}
canProposePacks={canProposePacks}/>;
})}
</div>
</div>
);
};
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested }) => {
const MachinesListWrapper: React.FC<MachinesListProps> = ({ user, onError, onSuccess, onShowMachine, onReserveMachine, onLoginRequested, onEnrollRequested, canProposePacks }) => {
return (
<Loader>
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} />
<MachinesList user={user} onError={onError} onSuccess={onSuccess} onShowMachine={onShowMachine} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} canProposePacks={canProposePacks}/>
</Loader>
);
};
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onSuccess', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested']));
Application.Components.component('machinesList', react2angular(MachinesListWrapper, ['user', 'onError', 'onSuccess', 'onShowMachine', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'canProposePacks']));

View File

@ -42,7 +42,7 @@ export const RequiredTrainingModal: React.FC<RequiredTrainingModalProps> = ({ is
const header = (): ReactNode => {
return (
<div className="user-info">
<Avatar user={user} />
<Avatar userName={user?.name} avatar={user?.profile_attributes?.user_avatar_attributes?.attachment} />
<span className="user-name">{user?.name}</span>
</div>
);

View File

@ -24,13 +24,14 @@ interface ReserveButtonProps {
onReserveMachine: (machine: Machine) => void,
onLoginRequested: () => Promise<User>,
onEnrollRequested: (trainingId: number) => void,
className?: string
className?: string,
canProposePacks: boolean,
}
/**
* Button component that makes the training verification before redirecting the user to the reservation calendar
*/
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children, canProposePacks }) => {
const { t } = useTranslation('shared');
const [machine, setMachine] = useState<Machine>(null);
@ -146,7 +147,7 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
// if the customer has already bought a pack or if there's no active packs for this machine,
// or customer has not any subscription if admin active pack only for subscription option
// let the customer reserve
if (machine.current_user_has_packs || !machine.has_prepaid_packs_for_current_user || (isPackOnlyForSubscription && !user.subscribed_plan)) {
if (machine.current_user_has_packs || !machine.has_prepaid_packs_for_current_user || (isPackOnlyForSubscription && !user.subscribed_plan) || !canProposePacks) {
return onReserveMachine(machine);
}
@ -182,14 +183,14 @@ const ReserveButtonComponent: React.FC<ReserveButtonProps> = ({ currentUser, mac
);
};
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children }) => {
export const ReserveButton: React.FC<ReserveButtonProps> = ({ currentUser, machineId, onLoginRequested, onLoadingStart, onLoadingEnd, onError, onSuccess, onReserveMachine, onEnrollRequested, className, children, canProposePacks }) => {
return (
<Loader>
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onSuccess={onSuccess} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className}>
<ReserveButtonComponent currentUser={currentUser} machineId={machineId} onError={onError} onSuccess={onSuccess} onLoadingStart={onLoadingStart} onLoadingEnd={onLoadingEnd} onReserveMachine={onReserveMachine} onLoginRequested={onLoginRequested} onEnrollRequested={onEnrollRequested} className={className} canProposePacks={canProposePacks}>
{children}
</ReserveButtonComponent>
</Loader>
);
};
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onSuccess', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className']));
Application.Components.component('reserveButton', react2angular(ReserveButton, ['currentUser', 'machineId', 'onLoadingStart', 'onLoadingEnd', 'onError', 'onSuccess', 'onReserveMachine', 'onLoginRequested', 'onEnrollRequested', 'className', 'canProposePacks']));

View File

@ -7,7 +7,7 @@ import {
import React, { ReactElement, useState } from 'react';
import { FabButton } from '../base/fab-button';
import { useTranslation } from 'react-i18next';
import { User, UserRole } from '../../models/user';
import { User } from '../../models/user';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { FabModal } from '../base/fab-modal';
import FormatLib from '../../lib/format';
@ -58,7 +58,7 @@ export const PaymentScheduleItemActions: React.FC<PaymentScheduleItemActionsProp
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator.role === UserRole.Admin || operator.role === UserRole.Manager);
return (operator.role === 'admin' || operator.role === 'manager');
};
/**

View File

@ -9,6 +9,7 @@ import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { TDateISODate } from '../../typings/date-iso';
declare const Application: IApplication;
@ -36,7 +37,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
// current filter, by customer's name, for the schedules
const [customerFilter, setCustomerFilter] = useState<string>(null);
// current filter, by date, for the schedules and the deadlines
const [dateFilter, setDateFilter] = useState<Date>(null);
const [dateFilter, setDateFilter] = useState<TDateISODate>(null);
/**
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.

View File

@ -14,6 +14,7 @@ import WalletAPI from '../../api/wallet';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import { GoogleTagManager } from '../../models/gtm';
import { ComputePriceResult } from '../../models/price';
import { Wallet } from '../../models/wallet';
import FormatLib from '../../lib/format';
@ -52,7 +53,7 @@ interface AbstractPaymentModalProps {
modalSize?: ModalSize,
}
declare const GTM: any;
declare const GTM: GoogleTagManager;
/**
* This component is an abstract modal that must be extended by each payment gateway to include its payment form.

View File

@ -11,7 +11,7 @@ import { PaymentSchedule } from '../../../models/payment-schedule';
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => {
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, formId }) => {
const { t } = useTranslation('shared');
const stripe = useStripe();

View File

@ -1,122 +0,0 @@
import React, { BaseSyntheticEvent, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PlanCategoryAPI from '../../api/plan-category';
import { PlanCategory } from '../../models/plan-category';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { LabelledInput } from '../base/labelled-input';
import { Loader } from '../base/loader';
import { FabAlert } from '../base/fab-alert';
interface CreatePlanCategoryProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog allowing to fill the parameters with a new plan-category.
*/
const CreatePlanCategoryComponent: React.FC<CreatePlanCategoryProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [category, setCategory] = useState<PlanCategory>(null);
// is the creation modal open?
const [isOpen, setIsOpen] = useState<boolean>(false);
/**
* Opens/closes the new plan-category (creation) modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* The creation has been confirmed by the user.
* Push the new plan-category to the API.
*/
const onCreateConfirmed = (): void => {
PlanCategoryAPI.create(category).then(() => {
onSuccess(t('app.admin.create_plan_category.category_created'));
resetCategory();
toggleModal();
}).catch((error) => {
onError(t('app.admin.create_plan_category.unable_to_create') + error);
});
};
/**
* Callback triggered when the user is changing the name of the category in the modal dialog.
* We update the name of the temporary-set plan-category, accordingly.
*/
const onCategoryNameChange = (event: BaseSyntheticEvent) => {
setCategory({ ...category, name: event.target.value });
};
/**
* Callback triggered when the user is changing the weight of the category in the modal dialog.
* We update the weight of the temporary-set plan-category, accordingly.
*/
const onCategoryWeightChange = (event: BaseSyntheticEvent) => {
setCategory({ ...category, weight: event.target.value });
};
/**
* Initialize a new plan-category for creation
*/
const initCategoryCreation = () => {
setCategory({ name: '', weight: 0 });
};
/**
* Reinitialize the category to prevent ghost data
*/
const resetCategory = () => {
setCategory(null);
};
return (
<div className="create-plan-category">
<FabButton type='button'
icon={<i className='fa fa-plus' />}
className="add-category"
onClick={toggleModal}>
{t('app.admin.create_plan_category.new_category')}
</FabButton>
<FabModal title={t('app.admin.create_plan_category.new_category')}
className="create-plan-category-modal"
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.admin.create_plan_category.confirm_create')}
onConfirm={onCreateConfirmed}
onCreation={initCategoryCreation}>
{category && <div>
<label htmlFor="name">{t('app.admin.create_plan_category.name')}</label>
<LabelledInput id="name"
label={<i className="fa fa-tag" />}
type="text"
value={category.name}
onChange={onCategoryNameChange} />
<label htmlFor="weight">{t('app.admin.create_plan_category.significance')}</label>
<LabelledInput id="weight"
type="number"
label={<i className="fa fa-sort-numeric-desc" />}
value={category.weight}
onChange={onCategoryWeightChange} />
</div>}
<FabAlert level="info" className="significance-info">
{t('app.admin.create_plan_category.significance_info')}
</FabAlert>
</FabModal>
</div>
);
};
export const CreatePlanCategory: React.FC<CreatePlanCategoryProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<CreatePlanCategoryComponent onSuccess={onSuccess} onError={onError} />
</Loader>
);
};

View File

@ -34,9 +34,9 @@ const DeletePlanCategoryComponent: React.FC<DeletePlanCategoryProps> = ({ onSucc
*/
const onDeleteConfirmed = (): void => {
PlanCategoryAPI.destroy(category.id).then(() => {
onSuccess(t('app.admin.delete_plan_category.category_deleted'));
onSuccess(t('app.admin.manage_plan_category.delete_category.success'));
}).catch((error) => {
onError(t('app.admin.delete_plan_category.unable_to_delete') + error);
onError(t('app.admin.manage_plan_category.delete_category.error') + error);
});
toggleDeletionModal();
};
@ -44,13 +44,13 @@ const DeletePlanCategoryComponent: React.FC<DeletePlanCategoryProps> = ({ onSucc
return (
<div className="delete-plan-category">
<FabButton type='button' className="delete-button" icon={<i className="fa fa-trash" />} onClick={toggleDeletionModal} />
<FabModal title={t('app.admin.delete_plan_category.delete_category')}
<FabModal title={t('app.admin.manage_plan_category.delete_category.title')}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}
confirmButton={t('app.admin.delete_plan_category.confirm_delete')}
confirmButton={t('app.admin.manage_plan_category.delete_category.cta')}
onConfirm={onDeleteConfirmed}>
<span>{t('app.admin.delete_plan_category.delete_confirmation')}</span>
<span>{t('app.admin.manage_plan_category.delete_category.confirm')}</span>
</FabModal>
</div>
);

View File

@ -1,104 +0,0 @@
import React, { BaseSyntheticEvent, useState } from 'react';
import { useTranslation } from 'react-i18next';
import PlanCategoryAPI from '../../api/plan-category';
import { PlanCategory } from '../../models/plan-category';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { LabelledInput } from '../base/labelled-input';
import { Loader } from '../base/loader';
import { FabAlert } from '../base/fab-alert';
interface EditPlanCategoryProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
category: PlanCategory
}
/**
* This component shows an edit button.
* When clicked, we show a modal dialog allowing to edit the parameters of the provided plan-category.
*/
const EditPlanCategoryComponent: React.FC<EditPlanCategoryProps> = ({ onSuccess, onError, category }) => {
const { t } = useTranslation('admin');
// is the edition modal open?
const [editionModal, setEditionModal] = useState<boolean>(false);
// when editing, we store the category here, until the edition is over
const [tempCategory, setTempCategory] = useState<PlanCategory>(category);
/**
* Opens/closes the edition modal
*/
const toggleEditionModal = (): void => {
setEditionModal(!editionModal);
};
/**
* The edit has been confirmed by the user.
* Call the API to trigger the update of the temporary set plan-category.
*/
const onEditConfirmed = (): void => {
PlanCategoryAPI.update(tempCategory).then((updatedCategory) => {
onSuccess(t('app.admin.edit_plan_category.category_updated'));
setTempCategory(updatedCategory);
toggleEditionModal();
}).catch((error) => {
onError(t('app.admin.edit_plan_category.unable_to_update') + error);
});
};
/**
* Callback triggered when the user is changing the name of the category in the modal dialog.
* We update the name of the temporary-set plan-category, accordingly.
*/
const onCategoryNameChange = (event: BaseSyntheticEvent) => {
setTempCategory({ ...tempCategory, name: event.target.value });
};
/**
* Callback triggered when the user is changing the weight of the category in the modal dialog.
* We update the weight of the temporary-set plan-category, accordingly.
*/
const onCategoryWeightChange = (event: BaseSyntheticEvent) => {
setTempCategory({ ...tempCategory, weight: event.target.value });
};
return (
<div className="edit-plan-category">
<FabButton type='button' className="edit-button" icon={<i className="fa fa-edit" />} onClick={toggleEditionModal} />
<FabModal title={t('app.admin.edit_plan_category.edit_category')}
isOpen={editionModal}
toggleModal={toggleEditionModal}
className="edit-plan-category-modal"
closeButton={true}
confirmButton={t('app.admin.edit_plan_category.confirm_edition')}
onConfirm={onEditConfirmed}>
{tempCategory && <div>
<label htmlFor="category-name">{t('app.admin.edit_plan_category.name')}</label>
<LabelledInput id="category-name"
type="text"
label={<i className="fa fa-tag" />}
value={tempCategory.name}
onChange={onCategoryNameChange} />
<label htmlFor="category-weight">{t('app.admin.edit_plan_category.significance')}</label>
<LabelledInput id="category-weight"
type="number"
label={<i className="fa fa-sort-numeric-desc" />}
value={tempCategory.weight}
onChange={onCategoryWeightChange} />
</div>}
<FabAlert level="info" className="significance-info">
{t('app.admin.edit_plan_category.significance_info')}
</FabAlert>
</FabModal>
</div>
);
};
export const EditPlanCategory: React.FC<EditPlanCategoryProps> = ({ onSuccess, onError, category }) => {
return (
<Loader>
<EditPlanCategoryComponent onSuccess={onSuccess} onError={onError} category={category} />
</Loader>
);
};

View File

@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PlanCategory } from '../../models/plan-category';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { Loader } from '../base/loader';
import { PlanCategoryForm } from './plan-category-form';
interface ManagePlanCategoryProps {
category?: PlanCategory,
action: 'create' | 'update',
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a button.
* When clicked, we show a modal dialog allowing to fill the parameters of a plan-category (create new or update existing).
*/
const ManagePlanCategoryComponent: React.FC<ManagePlanCategoryProps> = ({ category, action, onSuccess, onError }) => {
const { t } = useTranslation('admin');
// is the creation modal open?
const [isOpen, setIsOpen] = useState<boolean>(false);
// when editing, we store the category here, until the edition is over
const [tempCategory, setTempCategory] = useState<PlanCategory>(category);
/**
* Opens/closes the new plan-category (creation) modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Initialize a new plan-category for creation
* or refresh plan-category data for update
*/
const initCategoryCreation = () => {
if (action === 'create') {
setTempCategory({ name: '', description: '', weight: 0 });
} else {
setTempCategory(category);
}
};
/**
* Close the modal if the form submission was successful
*/
const handleSuccess = (message) => {
setIsOpen(false);
onSuccess(message);
};
/**
* Render the appropriate button depending on the action type
*/
const toggleBtn = () => {
switch (action) {
case 'create':
return (
<FabButton type='button'
icon={<i className='fa fa-plus' />}
className="btn-warning"
onClick={toggleModal}>
{t('app.admin.create_plan_category.new_category')}
</FabButton>
);
case 'update':
return (<FabButton type='button'
icon={<i className="fa fa-edit" />}
className="edit-button"
onClick={toggleModal} />);
}
};
return (
<div className='manage-plan-category'>
{ toggleBtn() }
<FabModal title={t(`app.admin.manage_plan_category.${action}_category.title`)}
isOpen={isOpen}
toggleModal={toggleModal}
onCreation={initCategoryCreation}
closeButton>
{tempCategory && <PlanCategoryForm action={action} category={tempCategory} onSuccess={handleSuccess} onError={onError} />}
</FabModal>
</div>
);
};
export const ManagePlanCategory: React.FC<ManagePlanCategoryProps> = ({ category, action, onSuccess, onError }) => {
return (
<Loader>
<ManagePlanCategoryComponent category={category} action={action} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};

View File

@ -5,8 +5,7 @@ import { PlanCategory } from '../../models/plan-category';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { CreatePlanCategory } from './create-plan-category';
import { EditPlanCategory } from './edit-plan-category';
import { ManagePlanCategory } from './manage-plan-category';
import { DeletePlanCategory } from './delete-plan-category';
declare const Application: IApplication;
@ -51,15 +50,15 @@ export const PlanCategoriesList: React.FC<PlanCategoriesListProps> = ({ onSucces
return (
<div className="plan-categories-list">
<CreatePlanCategory onSuccess={handleSuccess}
onError={onError} />
<ManagePlanCategory action='create' onSuccess={handleSuccess} onError={onError} />
<h3>{t('app.admin.plan_categories_list.categories_list')}</h3>
{categories && categories.length === 0 && <span>{t('app.admin.plan_categories_list.no_categories')}</span>}
{categories && categories.length > 0 && <table className="categories-table">
<thead>
<tr>
<th style={{ width: '66%' }}>{t('app.admin.plan_categories_list.name')}</th>
<th>{t('app.admin.plan_categories_list.significance')} <i className="fa fa-sort-numeric-desc" /></th>
<th>{t('app.admin.plan_categories_list.name')}</th>
<th className="category-weight">{t('app.admin.plan_categories_list.significance')} <i className="fa fa-sort-numeric-desc" /></th>
<th className="category-actions"></th>
</tr>
</thead>
<tbody>
@ -68,7 +67,7 @@ export const PlanCategoriesList: React.FC<PlanCategoriesListProps> = ({ onSucces
<td className="category-name">{c.name}</td>
<td className="category-weight">{c.weight}</td>
<td className="category-actions">
<EditPlanCategory onSuccess={handleSuccess} onError={onError} category={c} />
<ManagePlanCategory action='update' onSuccess={handleSuccess} onError={onError} category={c} />
<DeletePlanCategory onSuccess={handleSuccess} onError={onError} category={c} />
</td>
</tr>)}

View File

@ -0,0 +1,67 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import PlanCategoryAPI from '../../api/plan-category';
import { PlanCategory } from '../../models/plan-category';
import { Loader } from '../base/loader';
import { useForm, SubmitHandler } from 'react-hook-form';
import { FormInput } from '../form/form-input';
import { FabAlert } from '../base/fab-alert';
import { FabButton } from '../base/fab-button';
import { FormRichText } from '../form/form-rich-text';
interface PlanCategoryFormProps {
action: 'create' | 'update',
category: PlanCategory,
onSuccess: (message: string) => void,
onError: (message: string) => void
}
const PlanCategoryFormComponent: React.FC<PlanCategoryFormProps> = ({ action, category, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const { register, control, handleSubmit } = useForm<PlanCategory>({ defaultValues: { ...category } });
/**
* The action has been confirmed by the user.
* Push the created/updated plan-category to the API.
*/
const onSubmit: SubmitHandler<PlanCategory> = (data: PlanCategory) => {
switch (action) {
case 'create':
PlanCategoryAPI.create(data).then(() => {
onSuccess(t('app.admin.manage_plan_category.create_category.success'));
}).catch((error) => {
onError(t('app.admin.manage_plan_category.create_category.error') + error);
});
break;
case 'update':
PlanCategoryAPI.update(data).then(() => {
onSuccess(t('app.admin.manage_plan_category.update_category.success'));
}).catch((error) => {
onError(t('app.admin.manage_plan_category.update_category.error') + error);
});
break;
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormInput id='name' register={register} rules={{ required: 'true' }} label={t('app.admin.manage_plan_category.name')} />
<FormRichText control={control} id="description" label={t('app.admin.manage_plan_category.description')} limit={100} />
<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>
<FabButton type='submit'>{t(`app.admin.manage_plan_category.${action}_category.cta`)}</FabButton>
</form>
);
};
export const PlanCategoryForm: React.FC<PlanCategoryFormProps> = ({ action, category, onSuccess, onError }) => {
return (
<Loader>
<PlanCategoryFormComponent action={action} category={category} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import moment from 'moment';
import _ from 'lodash';
import { Plan } from '../../models/plan';
import { User, UserRole } from '../../models/user';
import { User } from '../../models/user';
import { Loader } from '../base/loader';
import '../../lib/i18n';
import FormatLib from '../../lib/format';
@ -14,6 +14,7 @@ interface PlanCardProps {
subscribedPlanId?: number,
operator: User,
isSelected: boolean,
canSelectPlan: boolean,
onSelectPlan: (plan: Plan) => void,
onLoginRequested: () => void,
}
@ -21,7 +22,7 @@ interface PlanCardProps {
/**
* This component is a "card" (visually), publicly presenting the details of a plan and allowing a user to subscribe.
*/
const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested, canSelectPlan }) => {
const { t } = useTranslation('public');
/**
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
@ -52,13 +53,13 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
* Check if the user can subscribe to the current plan, for himself
*/
const canSubscribeForMe = (): boolean => {
return operator?.role === UserRole.Member || (operator?.role === UserRole.Manager && userId === operator?.id);
return operator?.role === 'member' || (operator?.role === 'manager' && userId === operator?.id);
};
/**
* Check if the user can subscribe to the current plan, for someone else
*/
const canSubscribeForOther = (): boolean => {
return operator?.role === UserRole.Admin || (operator?.role === UserRole.Manager && userId !== operator?.id);
return operator?.role === 'admin' || (operator?.role === 'manager' && userId !== operator?.id);
};
/**
* Check it the user has subscribed to this plan or not
@ -88,7 +89,9 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
* Callback triggered when the user select the plan
*/
const handleSelectPlan = (): void => {
onSelectPlan(plan);
if (canSelectPlan) {
onSelectPlan(plan);
}
};
/**
* Callback triggered when a visitor (not logged-in user) select a plan
@ -141,10 +144,10 @@ const PlanCardComponent: React.FC<PlanCardProps> = ({ plan, userId, subscribedPl
);
};
export const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
export const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested, canSelectPlan }) => {
return (
<Loader>
<PlanCardComponent plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan} onLoginRequested={onLoginRequested}/>
<PlanCardComponent plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan} onLoginRequested={onLoginRequested} canSelectPlan={canSelectPlan}/>
</Loader>
);
};

View File

@ -22,6 +22,7 @@ interface PlansListProps {
operator?: User,
customer?: User,
subscribedPlanId?: number,
canSelectPlan: boolean,
}
// A list of plans, organized by group ID - then organized by plan-category ID (or NaN if the plan has no category)
@ -30,7 +31,7 @@ type PlansTree = Map<number, Map<number, Array<Plan>>>;
/**
* This component display an organized list of plans to allow the end-user to select one and subscribe online
*/
const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLoginRequest, operator, customer, subscribedPlanId }) => {
const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLoginRequest, operator, customer, subscribedPlanId, canSelectPlan }) => {
// all plans
const [plans, setPlans] = useState<PlansTree>(null);
// all plan-categories, ordered by weight
@ -218,6 +219,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
operator={operator}
isSelected={isSelectedPlan(plan)}
onSelectPlan={handlePlanSelection}
canSelectPlan={canSelectPlan}
onLoginRequested={onLoginRequest} />
))}
</div>
@ -239,12 +241,12 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
);
};
const PlansListWrapper: React.FC<PlansListProps> = ({ customer, onError, onPlanSelection, onLoginRequest, operator, subscribedPlanId }) => {
const PlansListWrapper: React.FC<PlansListProps> = ({ customer, onError, onPlanSelection, onLoginRequest, operator, subscribedPlanId, canSelectPlan }) => {
return (
<Loader>
<PlansList customer={customer} onError={onError} onPlanSelection={onPlanSelection} onLoginRequest={onLoginRequest} operator={operator} subscribedPlanId={subscribedPlanId} />
<PlansList customer={customer} onError={onError} onPlanSelection={onPlanSelection} onLoginRequest={onLoginRequest} operator={operator} subscribedPlanId={subscribedPlanId} canSelectPlan={canSelectPlan} />
</Loader>
);
};
Application.Components.component('plansList', react2angular(PlansListWrapper, ['customer', 'onError', 'onPlanSelection', 'onLoginRequest', 'operator', 'subscribedPlanId']));
Application.Components.component('plansList', react2angular(PlansListWrapper, ['customer', 'onError', 'onPlanSelection', 'onLoginRequest', 'operator', 'subscribedPlanId', 'canSelectPlan']));

View File

@ -0,0 +1,70 @@
import React, { useEffect } from 'react';
import { ActiveProviderResponse } from '../../models/authentication-provider';
import { useTranslation } from 'react-i18next';
import { User } from '../../models/user';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import UserLib from '../../lib/user';
declare const Application: IApplication;
interface CompletionHeaderInfoProps {
user: User,
activeProvider: ActiveProviderResponse,
onError: (message: string) => void,
}
/**
* This component will show an information message, on the profile completion page.
*/
export const CompletionHeaderInfo: React.FC<CompletionHeaderInfoProps> = ({ user, activeProvider, onError }) => {
const { t } = useTranslation('logged');
const [settings, setSettings] = React.useState<Map<SettingName, string>>(null);
const userLib = new UserLib(user);
useEffect(() => {
SettingAPI.query([SettingName.NameGenre, SettingName.FablabName]).then(setSettings).catch(onError);
}, []);
return (
<div className="completion-header-info">
{activeProvider?.providable_type === 'DatabaseProvider' && <div className="header-info--local-database">
<p>{t('app.logged.profile_completion.completion_header_info.rules_changed')}</p>
</div>}
{activeProvider?.providable_type !== 'DatabaseProvider' && <div className="header-info--sso">
<p className="intro">
<span>
{t('app.logged.profile_completion.completion_header_info.sso_intro', {
GENDER: settings?.get(SettingName.NameGenre),
NAME: settings?.get(SettingName.FablabName)
})}
</span>
<span className="provider-name">
{activeProvider?.name}
{userLib.ssoEmail() && <span className="user-email">({ userLib.ssoEmail() })</span>}
</span>
</p>
{userLib.hasDuplicate() && <p className="duplicate-email-info">
{t('app.logged.profile_completion.completion_header_info.duplicate_email_info')}
</p>}
{!userLib.hasDuplicate() && <p className="details-needed-info">
{t('app.logged.profile_completion.completion_header_info.details_needed_info')}
</p>}
</div>}
</div>
);
};
const CompletionHeaderInfoWrapper: React.FC<CompletionHeaderInfoProps> = (props) => {
return (
<Loader>
<CompletionHeaderInfo {...props} />
</Loader>
);
};
Application.Components.component('completionHeaderInfo', react2angular(CompletionHeaderInfoWrapper, ['user', 'activeProvider', 'onError']));

View File

@ -0,0 +1,88 @@
import React from 'react';
import { User } from '../../models/user';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { ActiveProviderResponse } from '../../models/authentication-provider';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../base/html-translate';
import { UserProfileForm } from '../user/user-profile-form';
import UserLib from '../../lib/user';
import { FabButton } from '../base/fab-button';
import Authentication from '../../api/authentication';
declare const Application: IApplication;
interface ProfileFormOptionProps {
user: User,
activeProvider: ActiveProviderResponse,
onError: (message: string) => void,
onSuccess: (user: User) => void,
}
export const ProfileFormOption: React.FC<ProfileFormOptionProps> = ({ user, activeProvider, onError, onSuccess }) => {
const { t } = useTranslation('logged');
const userLib = new UserLib(user);
/**
* Route the current user to the interface provided by the authentication provider, to update his profile.
*/
const redirectToSsoProfile = (): void => {
window.open(activeProvider.link_to_sso_profile, '_blank');
};
/**
* Disconnect and re-connect the user to the SSO to force the synchronisation of the profile's data
*/
function syncProfile () {
Authentication.logout().then(() => {
window.location.href = activeProvider.link_to_sso_connect;
}).catch(onError);
}
return (
<div className="profile-form-option">
<h3>{t('app.logged.profile_completion.profile_form_option.title')}</h3>
{!userLib.hasDuplicate() && <div className="normal-flow">
<p>{t('app.logged.profile_completion.profile_form_option.please_fill')}</p>
<p className="disabled-fields-info">{t('app.logged.profile_completion.profile_form_option.disabled_data_from_sso', { NAME: activeProvider?.name })}</p>
<p className="confirm-instructions">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.confirm_instructions_html" />
</p>
<UserProfileForm onError={onError}
action="update"
user={user}
onSuccess={onSuccess}
size="small"
showGroupInput
showTermsAndConditionsInput />
</div>}
{userLib.hasDuplicate() && <div className="duplicate-email">
<p className="duplicate-info">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.duplicate_email_html"
options={{ EMAIL: userLib.ssoEmail(), PROVIDER: activeProvider?.name }} />
</p>
<FabButton onClick={redirectToSsoProfile} icon={<i className="fa fa-edit"/>}>
{t('app.logged.profile_completion.profile_form_option.edit_profile')}
</FabButton>
<p className="after-edition-info">
<HtmlTranslate trKey="app.logged.profile_completion.profile_form_option.after_edition_info_html" />
</p>
<FabButton onClick={syncProfile} icon={<i className="fa fa-refresh"/>}>
{t('app.logged.profile_completion.profile_form_option.sync_profile')}
</FabButton>
</div>}
</div>
);
};
const ProfileFormOptionWrapper: React.FC<ProfileFormOptionProps> = (props) => {
return (
<Loader>
<ProfileFormOption {...props} />
</Loader>
);
};
Application.Components.component('profileFormOption', react2angular(ProfileFormOptionWrapper, ['user', 'activeProvider', 'onError', 'onSuccess']));

View File

@ -0,0 +1,146 @@
import React, { useState, useEffect, BaseSyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import Switch from 'react-switch';
import _ from 'lodash';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ProfileCustomField } from '../../models/profile-custom-field';
import ProfileCustomFieldAPI from '../../api/profile-custom-field';
declare const Application: IApplication;
interface ProfileCustomFieldsListProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a list of all profile custom fields
*/
const ProfileCustomFieldsList: React.FC<ProfileCustomFieldsListProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [profileCustomFields, setProfileCustomFields] = useState<Array<ProfileCustomField>>([]);
const [profileCustomFieldToEdit, setProfileCustomFieldToEdit] = useState<ProfileCustomField>(null);
// get profile custom fields
useEffect(() => {
ProfileCustomFieldAPI.index().then(pData => {
setProfileCustomFields(pData);
});
}, []);
const saveProfileCustomField = (profileCustomField: ProfileCustomField) => {
ProfileCustomFieldAPI.update(profileCustomField).then(data => {
const newFields = profileCustomFields.map(f => {
if (f.id === data.id) {
return data;
}
return f;
});
setProfileCustomFields(newFields);
if (profileCustomFieldToEdit) {
setProfileCustomFieldToEdit(null);
}
onSuccess(t('app.admin.settings.compte.organization_profile_custom_field_successfully_updated'));
}).catch(err => {
onError(t('app.admin.settings.compte.organization_profile_custom_field_unable_to_update') + err);
});
};
/**
* Callback triggered when the 'switch' is changed.
*/
const handleSwitchChanged = (profileCustomField: ProfileCustomField, field: string) => {
return (value: boolean) => {
const _profileCustomField = _.clone(profileCustomField);
_profileCustomField[field] = value;
if (field === 'actived' && !value) {
_profileCustomField.required = false;
}
saveProfileCustomField(_profileCustomField);
};
};
const editProfileCustomFieldLabel = (profileCustomField: ProfileCustomField) => {
return () => {
setProfileCustomFieldToEdit(_.clone(profileCustomField));
};
};
const onChangeProfileCustomFieldLabel = (e: BaseSyntheticEvent) => {
const { value } = e.target;
setProfileCustomFieldToEdit({
...profileCustomFieldToEdit,
label: value
});
};
const saveProfileCustomFieldLabel = () => {
saveProfileCustomField(profileCustomFieldToEdit);
};
const cancelEditProfileCustomFieldLabel = () => {
setProfileCustomFieldToEdit(null);
};
return (
<table className="table profile-custom-fields-list">
<thead>
<tr>
<th style={{ width: '50%' }}></th>
<th style={{ width: '25%' }}></th>
<th style={{ width: '25%' }}></th>
</tr>
</thead>
<tbody>
{profileCustomFields.map(field => {
return (
<tr key={field.id}>
<td>
{profileCustomFieldToEdit?.id !== field.id && field.label}
{profileCustomFieldToEdit?.id !== field.id && (
<button className="btn btn-default edit-profile-custom-field-label m-r-xs pull-right" onClick={editProfileCustomFieldLabel(field)}>
<i className="fa fa-edit"></i>
</button>
)}
{profileCustomFieldToEdit?.id === field.id && (
<div>
<input className="profile-custom-field-label-input" style={{ width: '80%', height: '38px' }} type="text" value={profileCustomFieldToEdit.label} onChange={onChangeProfileCustomFieldLabel} />
<span className="buttons pull-right">
<button className="btn btn-success save-profile-custom-field-label m-r-xs" onClick={saveProfileCustomFieldLabel}>
<i className="fa fa-check"></i>
</button>
<button className="btn btn-default delete-profile-custom-field-label m-r-xs" onClick={cancelEditProfileCustomFieldLabel}>
<i className="fa fa-ban"></i>
</button>
</span>
</div>
)}
</td>
<td>
<label htmlFor="profile-custom-field-actived" className="control-label m-r">{t('app.admin.settings.compte.organization_profile_custom_field.actived')}</label>
<Switch checked={field.actived} id="profile-custom-field-actived" onChange={handleSwitchChanged(field, 'actived')} className="v-middle"></Switch>
</td>
<td>
<label htmlFor="profile-custom-field-required" className="control-label m-r">{t('app.admin.settings.compte.organization_profile_custom_field.required')}</label>
<Switch checked={field.required} disabled={!field.actived} id="profile-custom-field-required" onChange={handleSwitchChanged(field, 'required')} className="v-middle"></Switch>
</td>
</tr>
);
})}
</tbody>
</table>
);
};
const ProfileCustomFieldsListWrapper: React.FC<ProfileCustomFieldsListProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<ProfileCustomFieldsList onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('profileCustomFieldsList', react2angular(ProfileCustomFieldsListWrapper, ['onSuccess', 'onError']));

View File

@ -0,0 +1,37 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import ProofOfIdentityTypeAPI from '../../api/proof-of-identity-type';
interface DeleteProofOfIdentityTypeModalProps {
isOpen: boolean,
proofOfIdentityTypeId: number,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
export const DeleteProofOfIdentityTypeModal: React.FC<DeleteProofOfIdentityTypeModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypeId, onError }) => {
const { t } = useTranslation('admin');
const handleDeleteProofOfIdentityType = async (): Promise<void> => {
try {
await ProofOfIdentityTypeAPI.destroy(proofOfIdentityTypeId);
onSuccess(t('app.admin.settings.compte.proof_of_identity_type_deleted'));
} catch (e) {
onError(t('app.admin.settings.compte.proof_of_identity_type_unable_to_delete') + e);
}
};
return (
<FabModal title={t('app.admin.settings.compte.confirmation_required')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={true}
confirmButton={t('app.admin.settings.compte.confirm')}
onConfirm={handleDeleteProofOfIdentityType}
className="proof-of-identity-type-modal">
<p>{t('app.admin.settings.compte.do_you_really_want_to_delete_this_proof_of_identity_type')}</p>
</FabModal>
);
};

View File

@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import _ from 'lodash';
import { HtmlTranslate } from '../base/html-translate';
import { Loader } from '../base/loader';
import { User } from '../../models/user';
import { IApplication } from '../../models/application';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { ProofOfIdentityFile } from '../../models/proof-of-identity-file';
import ProofOfIdentityTypeAPI from '../../api/proof-of-identity-type';
import ProofOfIdentityFileAPI from '../../api/proof-of-identity-file';
declare const Application: IApplication;
interface ProofOfIdentityFilesProps {
currentUser: User,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
interface FilesType {
number?: File
}
/**
* This component upload the proof of identity file of member
*/
const ProofOfIdentityFiles: React.FC<ProofOfIdentityFilesProps> = ({ currentUser, onSuccess, onError }) => {
const { t } = useTranslation('admin');
// list of proof of identity type
const [proofOfIdentityTypes, setProofOfIdentityTypes] = useState<Array<ProofOfIdentityType>>([]);
const [proofOfIdentityFiles, setProofOfIdentityFiles] = useState<Array<ProofOfIdentityFile>>([]);
const [files, setFiles] = useState<FilesType>({});
const [errors, setErrors] = useState<Array<number>>([]);
// get proof of identity type and files
useEffect(() => {
ProofOfIdentityTypeAPI.index({ group_id: currentUser.group_id }).then(tData => {
setProofOfIdentityTypes(tData);
});
ProofOfIdentityFileAPI.index({ user_id: currentUser.id }).then(fData => {
setProofOfIdentityFiles(fData);
});
}, []);
const getProofOfIdentityFileByType = (proofOfIdentityTypeId: number): ProofOfIdentityFile => {
return _.find<ProofOfIdentityFile>(proofOfIdentityFiles, { proof_of_identity_type_id: proofOfIdentityTypeId });
};
const hasFile = (proofOfIdentityTypeId: number): boolean => {
return files[proofOfIdentityTypeId] || getProofOfIdentityFileByType(proofOfIdentityTypeId);
};
/**
* Check if the current collection of proof of identity types is empty or not.
*/
const hasProofOfIdentityTypes = (): boolean => {
return proofOfIdentityTypes.length > 0;
};
const onFileChange = (poitId: number) => {
return (event) => {
const fileSize = event.target.files[0].size;
let _errors = errors;
// 5m max
if (fileSize > 5242880) {
_errors = errors.concat(poitId);
setErrors(_errors);
} else {
_errors = errors.filter(e => e !== poitId);
}
setErrors(_errors);
setFiles({
...files,
[poitId]: event.target.files[0]
});
};
};
const onFileUpload = async () => {
try {
for (const proofOfIdentityTypeId of Object.keys(files)) {
const formData = new FormData();
formData.append('proof_of_identity_file[user_id]', currentUser.id.toString());
formData.append('proof_of_identity_file[proof_of_identity_type_id]', proofOfIdentityTypeId);
formData.append('proof_of_identity_file[attachment]', files[proofOfIdentityTypeId]);
const proofOfIdentityFile = getProofOfIdentityFileByType(parseInt(proofOfIdentityTypeId, 10));
if (proofOfIdentityFile) {
await ProofOfIdentityFileAPI.update(proofOfIdentityFile.id, formData);
} else {
await ProofOfIdentityFileAPI.create(formData);
}
}
if (Object.keys(files).length > 0) {
ProofOfIdentityFileAPI.index({ user_id: currentUser.id }).then(fData => {
setProofOfIdentityFiles(fData);
setFiles({});
onSuccess(t('app.admin.members_edit.proof_of_identity_files_successfully_uploaded'));
});
}
} catch (e) {
onError(t('app.admin.members_edit.proof_of_identity_files_unable_to_upload') + e);
}
};
const getProofOfIdentityFileUrl = (poifId: number) => {
return `/api/proof_of_identity_files/${poifId}/download`;
};
return (
<section className="panel panel-default bg-light m-lg col-sm-12 col-md-12 col-lg-9">
<h3>{t('app.admin.members_edit.proof_of_identity_files')}</h3>
<p className="text-black font-sbold">{t('app.admin.members_edit.my_documents_info')}</p>
<div className="alert alert-warning">
<HtmlTranslate trKey="app.admin.members_edit.my_documents_alert" />
</div>
<div className="widget-content no-bg auto">
{proofOfIdentityTypes.map((poit: ProofOfIdentityType) => {
return (
<div className={`form-group ${errors.includes(poit.id) ? 'has-error' : ''}`} key={poit.id}>
<label className="control-label m-r">{poit.name}</label>
<div className="fileinput input-group">
<div className="form-control">
{hasFile(poit.id) && (
<div>
<i className="glyphicon glyphicon-file fileinput-exists"></i> <span className="fileinput-filename">{files[poit.id]?.name || getProofOfIdentityFileByType(poit.id).attachment}</span>
</div>
)}
{getProofOfIdentityFileByType(poit.id) && !files[poit.id] && (
<a href={getProofOfIdentityFileUrl(getProofOfIdentityFileByType(poit.id).id)} target="_blank" style={{ position: 'absolute', right: '10px' }} rel="noreferrer"><i className="fa fa-download text-black "></i></a>
)}
</div>
<span className="input-group-addon btn btn-default btn-file">
{!hasFile(poit.id) && (
<span className="fileinput-new">Parcourir</span>
)}
{hasFile(poit.id) && (
<span className="fileinput-exists">Modifier</span>
)}
<input type="file"
accept="application/pdf,image/jpeg,image/jpg,image/png"
onChange={onFileChange(poit.id)}
required />
</span>
</div>
{errors.includes(poit.id) && <span className="help-block">{t('app.admin.members_edit.proof_of_identity_file_size_error')}</span>}
</div>
);
})}
</div>
{hasProofOfIdentityTypes() && (
<button type="button" className="btn btn-warning m-b m-t pull-right" onClick={onFileUpload} disabled={errors.length > 0}>{t('app.admin.members_edit.save')}</button>
)}
</section>
);
};
const ProofOfIdentityFilesWrapper: React.FC<ProofOfIdentityFilesProps> = ({ currentUser, onSuccess, onError }) => {
return (
<Loader>
<ProofOfIdentityFiles currentUser={currentUser} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('proofOfIdentityFiles', react2angular(ProofOfIdentityFilesWrapper, ['currentUser', 'onSuccess', 'onError']));

View File

@ -0,0 +1,75 @@
import React, { BaseSyntheticEvent, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
interface ProofOfIdentityRefusalFormProps {
proofOfIdentityTypes: Array<ProofOfIdentityType>,
onChange: (field: string, value: string | Array<number>) => void,
}
/**
* Form to set the stripe's public and private keys
*/
export const ProofOfIdentityRefusalForm: React.FC<ProofOfIdentityRefusalFormProps> = ({ proofOfIdentityTypes, onChange }) => {
const { t } = useTranslation('admin');
const [values, setValues] = useState<Array<number>>([]);
const [message, setMessage] = useState<string>('');
/**
* Callback triggered when the name has changed.
*/
const handleMessageChange = (e: BaseSyntheticEvent): void => {
const { value } = e.target;
setMessage(value);
onChange('message', value);
};
/**
* Callback triggered when a checkbox is ticked or unticked.
* This function construct the resulting string, by adding or deleting the provided option identifier.
*/
const handleProofOfIdnentityTypesChange = (value: number) => {
return (event: BaseSyntheticEvent) => {
let newValues: Array<number>;
if (event.target.checked) {
newValues = values.concat(value);
} else {
newValues = values.filter(x => x !== value);
}
setValues(newValues);
onChange('proof_of_identity_type_ids', newValues);
};
};
/**
* Verify if the provided option is currently ticked (i.e. included in the value string)
*/
const isChecked = (value: number) => {
return values.includes(value);
};
return (
<div className="proof-of-identity-type-form">
<form name="proofOfIdentityRefusalForm">
<div>
{proofOfIdentityTypes.map(type => <div key={type.id} className="">
<label htmlFor={`checkbox-${type.id}`}>{type.name}</label>
<input id={`checkbox-${type.id}`} className="pull-right" type="checkbox" checked={isChecked(type.id)} onChange={handleProofOfIdnentityTypesChange(type.id)} />
</div>)}
</div>
<div className="proof-of-identity-refusal-comment-textarea m-t">
<label htmlFor="proof-of-identity-refusal-comment">{t('app.admin.members_edit.proof_of_identity_refusal_comment')}</label>
<textarea
id="proof-of-identity-refusal-comment"
value={message}
placeholder={t('app.admin.members_edit.proof_of_identity_refuse_input_message')}
onChange={handleMessageChange}
style={{ width: '100%' }}
rows={5}
required/>
</div>
</form>
</div>
);
};

View File

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { ProofOfIdentityRefusal } from '../../models/proof-of-identity-refusal';
import { User } from '../../models/user';
import ProofOfIdentityRefusalAPI from '../../api/proof-of-identity-refusal';
import { ProofOfIdentityRefusalForm } from './proof-of-identity-refusal-form';
interface ProofOfIdentityRefusalModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
proofOfIdentityTypes: Array<ProofOfIdentityType>,
operator: User,
member: User
}
export const ProofOfIdentityRefusalModal: React.FC<ProofOfIdentityRefusalModalProps> = ({ isOpen, toggleModal, onSuccess, proofOfIdentityTypes, operator, member, onError }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<ProofOfIdentityRefusal>({
id: null,
operator_id: operator.id,
user_id: member.id,
proof_of_identity_type_ids: [],
message: ''
});
const handleProofOfIdentityRefusalChanged = (field: string, value: string | Array<number>) => {
setData({
...data,
[field]: value
});
};
const handleSaveProofOfIdentityRefusal = async (): Promise<void> => {
try {
await ProofOfIdentityRefusalAPI.create(data);
onSuccess(t('app.admin.members_edit.proof_of_identity_refusal_successfully_sent'));
} catch (e) {
onError(t('app.admin.members_edit.proof_of_identity_refusal_unable_to_send') + e);
}
};
const isPreventSaveProofOfIdentityRefusal = (): boolean => {
return !data.message || data.proof_of_identity_type_ids.length === 0;
};
return (
<FabModal title={t('app.admin.members_edit.proof_of_identity_refusal')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
confirmButton={t('app.admin.members_edit.confirm')}
onConfirm={handleSaveProofOfIdentityRefusal}
preventConfirm={isPreventSaveProofOfIdentityRefusal()}
className="proof-of-identity-type-modal">
<ProofOfIdentityRefusalForm proofOfIdentityTypes={proofOfIdentityTypes} onChange={handleProofOfIdentityRefusalChanged}/>
</FabModal>
);
};

View File

@ -0,0 +1,90 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Select from 'react-select';
import { FabInput } from '../base/fab-input';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { Group } from '../../models/group';
interface ProofOfIdentityTypeFormProps {
groups: Array<Group>,
proofOfIdentityType?: ProofOfIdentityType,
onChange: (field: string, value: string | Array<number>) => void,
}
/**
* Option format, expected by react-select
* @see https://github.com/JedWatson/react-select
*/
type selectOption = { value: number, label: string };
/**
* Form to set the stripe's public and private keys
*/
export const ProofOfIdentityTypeForm: React.FC<ProofOfIdentityTypeFormProps> = ({ groups, proofOfIdentityType, onChange }) => {
const { t } = useTranslation('admin');
/**
* Convert all themes to the react-select format
*/
const buildOptions = (): Array<selectOption> => {
return groups.map(t => {
return { value: t.id, label: t.name };
});
};
/**
* Return the current groups(s), formatted to match the react-select format
*/
const groupsValues = (): Array<selectOption> => {
const res = [];
const groupIds = proofOfIdentityType?.group_ids || [];
if (groupIds.length > 0) {
groups.forEach(t => {
if (groupIds.indexOf(t.id) > -1) {
res.push({ value: t.id, label: t.name });
}
});
}
return res;
};
/**
* Callback triggered when the selection of group has changed.
*/
const handleGroupsChange = (selectedOptions: Array<selectOption>): void => {
onChange('group_ids', selectedOptions.map(o => o.value));
};
/**
* Callback triggered when the name has changed.
*/
const handleNameChange = (value: string): void => {
onChange('name', value);
};
return (
<div className="proof-of-identity-type-form">
<div className="proof-of-identity-type-form-info">
{t('app.admin.settings.compte.proof_of_identity_type_form_info')}
</div>
<form name="proofOfIdentityTypeForm">
<div className="proof-of-identity-type-select m-t">
<Select defaultValue={groupsValues()}
placeholder={t('app.admin.settings.compte.proof_of_identity_type_select_group')}
onChange={handleGroupsChange}
options={buildOptions()}
isMulti />
</div>
<div className="proof-of-identity-type-input m-t">
<FabInput id="proof_of_identity_type_name"
icon={<i className="fa fa-edit" />}
defaultValue={proofOfIdentityType?.name || ''}
placeholder={t('app.admin.settings.compte.proof_of_identity_type_input_name')}
onChange={handleNameChange}
debounce={200}
required/>
</div>
</form>
</div>
);
};

View File

@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../base/fab-modal';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { Group } from '../../models/group';
import ProofOfIdentityTypeAPI from '../../api/proof-of-identity-type';
import { ProofOfIdentityTypeForm } from './proof-of-identity-type-form';
interface ProofOfIdentityTypeModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
groups: Array<Group>,
proofOfIdentityType?: ProofOfIdentityType,
}
export const ProofOfIdentityTypeModal: React.FC<ProofOfIdentityTypeModalProps> = ({ isOpen, toggleModal, onSuccess, onError, proofOfIdentityType, groups }) => {
const { t } = useTranslation('admin');
const [data, setData] = useState<ProofOfIdentityType>({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
useEffect(() => {
setData({ id: proofOfIdentityType?.id, group_ids: proofOfIdentityType?.group_ids || [], name: proofOfIdentityType?.name || '' });
}, [proofOfIdentityType]);
const handleProofOfIdentityTypeChanged = (field: string, value: string | Array<number>) => {
setData({
...data,
[field]: value
});
};
const handleSaveProofOfIdentityType = async (): Promise<void> => {
try {
if (proofOfIdentityType?.id) {
await ProofOfIdentityTypeAPI.update(data);
onSuccess(t('app.admin.settings.compte.proof_of_identity_type_successfully_updated'));
} else {
await ProofOfIdentityTypeAPI.create(data);
onSuccess(t('app.admin.settings.compte.proof_of_identity_type_successfully_created'));
}
} catch (e) {
if (proofOfIdentityType?.id) {
onError(t('app.admin.settings.compte.proof_of_identity_type_unable_to_update') + e);
} else {
onError(t('app.admin.settings.compte.proof_of_identity_type_unable_to_create') + e);
}
}
};
const isPreventSaveProofOfIdentityType = (): boolean => {
return !data.name || data.group_ids.length === 0;
};
return (
<FabModal title={t(`app.admin.settings.compte.${proofOfIdentityType ? 'edit' : 'new'}_proof_of_identity_type`)}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
confirmButton={t(`app.admin.settings.compte.${proofOfIdentityType ? 'edit' : 'create'}`)}
onConfirm={handleSaveProofOfIdentityType}
preventConfirm={isPreventSaveProofOfIdentityType()}
className="proof-of-identity-type-modal">
<ProofOfIdentityTypeForm proofOfIdentityType={proofOfIdentityType} groups={groups} onChange={handleProofOfIdentityTypeChanged}/>
</FabModal>
);
};

View File

@ -0,0 +1,214 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import _ from 'lodash';
import { HtmlTranslate } from '../base/html-translate';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { Group } from '../../models/group';
import { ProofOfIdentityTypeModal } from './proof-of-identity-type-modal';
import { DeleteProofOfIdentityTypeModal } from './delete-proof-of-identity-type-modal';
import GroupAPI from '../../api/group';
import ProofOfIdentityTypeAPI from '../../api/proof-of-identity-type';
declare const Application: IApplication;
interface ProofOfIdentityTypesListProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
const ProofOfIdentityTypesList: React.FC<ProofOfIdentityTypesListProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
// list of displayed proof of identity type
const [proofOfIdentityTypes, setProofOfIdentityTypes] = useState<Array<ProofOfIdentityType>>([]);
const [proofOfIdentityType, setProofOfIdentityType] = useState<ProofOfIdentityType>(null);
const [proofOfIdentityTypeOrder, setProofOfIdentityTypeOrder] = useState<string>(null);
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
const [groups, setGroups] = useState<Array<Group>>([]);
const [destroyModalIsOpen, setDestroyModalIsOpen] = useState<boolean>(false);
const [proofOfIdentityTypeId, setProofOfIdentityTypeId] = useState<number>(null);
// get groups
useEffect(() => {
GroupAPI.index({ disabled: false, admins: false }).then(data => {
setGroups(data);
ProofOfIdentityTypeAPI.index().then(pData => {
setProofOfIdentityTypes(pData);
});
});
}, []);
/**
* Check if the current collection of proof of identity types is empty or not.
*/
const hasProofOfIdentityTypes = (): boolean => {
return proofOfIdentityTypes.length > 0;
};
const addProofOfIdentityType = (): void => {
setProofOfIdentityType(null);
setModalIsOpen(true);
};
const editProofOfIdentityType = (poit: ProofOfIdentityType): () => void => {
return (): void => {
setProofOfIdentityType(poit);
setModalIsOpen(true);
};
};
const toggleCreateAndEditModal = (): void => {
setModalIsOpen(false);
};
const saveProofOfIdentityTypeOnSuccess = (message: string): void => {
setModalIsOpen(false);
ProofOfIdentityTypeAPI.index().then(pData => {
setProofOfIdentityTypes(orderProofOfIdentityTypes(pData, proofOfIdentityTypeOrder));
onSuccess(message);
}).catch((error) => {
onError('Unable to load proof of identity types' + error);
});
};
const destroyProofOfIdentityType = (id: number): () => void => {
return (): void => {
setProofOfIdentityTypeId(id);
setDestroyModalIsOpen(true);
};
};
const toggleDestroyModal = (): void => {
setDestroyModalIsOpen(false);
};
const destroyProofOfIdentityTypeOnSuccess = (message: string): void => {
setDestroyModalIsOpen(false);
ProofOfIdentityTypeAPI.index().then(pData => {
setProofOfIdentityTypes(pData);
setProofOfIdentityTypes(orderProofOfIdentityTypes(pData, proofOfIdentityTypeOrder));
onSuccess(message);
}).catch((error) => {
onError('Unable to load proof of identity types' + error);
});
};
const setOrderProofOfIdentityType = (orderBy: string): () => void => {
return () => {
let order = orderBy;
if (proofOfIdentityTypeOrder === orderBy) {
order = `-${orderBy}`;
}
setProofOfIdentityTypeOrder(order);
setProofOfIdentityTypes(orderProofOfIdentityTypes(proofOfIdentityTypes, order));
};
};
const orderProofOfIdentityTypes = (poits: Array<ProofOfIdentityType>, orderBy?: string): Array<ProofOfIdentityType> => {
if (!orderBy) {
return poits;
}
const order = orderBy[0] === '-' ? 'desc' : 'asc';
if (orderBy.search('group_name') !== -1) {
return _.orderBy(poits, (poit: ProofOfIdentityType) => getGroupName(poit.group_ids), order);
} else {
return _.orderBy(poits, 'name', order);
}
};
const orderClassName = (orderBy: string): string => {
if (proofOfIdentityTypeOrder) {
const order = proofOfIdentityTypeOrder[0] === '-' ? proofOfIdentityTypeOrder.substr(1) : proofOfIdentityTypeOrder;
if (order === orderBy) {
return `fa fa-arrows-v ${proofOfIdentityTypeOrder[0] === '-' ? 'fa-sort-alpha-desc' : 'fa-sort-alpha-asc'}`;
}
}
return 'fa fa-arrows-v';
};
const getGroupName = (groupIds: Array<number>): string => {
if (groupIds.length === groups.length && groupIds.length > 0) {
return t('app.admin.settings.compte.all_groups');
}
const _groups = _.filter(groups, (g: Group) => { return groupIds.includes(g.id); });
return _groups.map((g: Group) => g.name).join(', ');
};
return (
<div className="panel panel-default m-t-md">
<div className="panel-heading">
<span className="font-sbold">{t('app.admin.settings.compte.add_proof_of_identity_types')}</span>
</div>
<div className="panel-body">
<div className="row">
<p className="m-h">{t('app.admin.settings.compte.proof_of_identity_type_info')}</p>
<div className="alert alert-warning m-h-md row">
<div className="col-md-8">
<HtmlTranslate trKey="app.admin.settings.compte.proof_of_identity_type_no_group_info" />
</div>
<a href="/#!/admin/members?tabs=1" className="btn btn-warning pull-right m-t m-r-md col-md-3" style={{ color: '#000' }}>{t('app.admin.settings.compte.create_groups')}</a>
</div>
</div>
<div className="row">
<h3 className="m-l inline">{t('app.admin.settings.compte.proof_of_identity_type_title')}</h3>
<button name="button" className="btn btn-warning pull-right m-t m-r-md" onClick={addProofOfIdentityType}>{t('app.admin.settings.compte.add_proof_of_identity_type_button')}</button>
</div>
<ProofOfIdentityTypeModal isOpen={modalIsOpen} groups={groups} proofOfIdentityType={proofOfIdentityType} toggleModal={toggleCreateAndEditModal} onSuccess={saveProofOfIdentityTypeOnSuccess} onError={onError} />
<DeleteProofOfIdentityTypeModal isOpen={destroyModalIsOpen} proofOfIdentityTypeId={proofOfIdentityTypeId} toggleModal={toggleDestroyModal} onSuccess={destroyProofOfIdentityTypeOnSuccess} onError={onError}/>
<table className="table proof-of-identity-type-list">
<thead>
<tr>
<th style={{ width: '40%' }}><a onClick={setOrderProofOfIdentityType('group_name')}>{t('app.admin.settings.compte.proof_of_identity_type.group_name')} <i className={orderClassName('group_name')}></i></a></th>
<th style={{ width: '40%' }}><a onClick={setOrderProofOfIdentityType('name')}>{t('app.admin.settings.compte.proof_of_identity_type.name')} <i className={orderClassName('name')}></i></a></th>
<th style={{ width: '20%' }} className="buttons-col"></th>
</tr>
</thead>
<tbody>
{proofOfIdentityTypes.map(poit => {
return (
<tr key={poit.id}>
<td>{getGroupName(poit.group_ids)}</td>
<td>{poit.name}</td>
<td>
<div className="buttons">
<button className="btn btn-default edit-proof-of-identity-type m-r-xs" onClick={editProofOfIdentityType(poit)}>
<i className="fa fa-edit"></i>
</button>
<button className="btn btn-danger delete-proof-of-identity-type" onClick={destroyProofOfIdentityType(poit.id)}>
<i className="fa fa-trash"></i>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{!hasProofOfIdentityTypes() && (
<p className="text-center">
<HtmlTranslate trKey="app.admin.settings.compte.no_proof_of_identity_types" />
</p>
)}
</div>
</div>
);
};
const ProofOfIdentityTypesListWrapper: React.FC<ProofOfIdentityTypesListProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<ProofOfIdentityTypesList onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('proofOfIdentityTypesList', react2angular(ProofOfIdentityTypesListWrapper, ['onSuccess', 'onError']));

View File

@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import _ from 'lodash';
import { Loader } from '../base/loader';
import { User } from '../../models/user';
import { IApplication } from '../../models/application';
import { ProofOfIdentityType } from '../../models/proof-of-identity-type';
import { ProofOfIdentityFile } from '../../models/proof-of-identity-file';
import ProofOfIdentityTypeAPI from '../../api/proof-of-identity-type';
import ProofOfIdentityFileAPI from '../../api/proof-of-identity-file';
import { ProofOfIdentityRefusalModal } from './proof-of-identity-refusal-modal';
declare const Application: IApplication;
interface ProofOfIdentityValidationProps {
operator: User,
member: User
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a list of proof of identity file of member, admin can download and valid
**/
const ProofOfIdentityValidation: React.FC<ProofOfIdentityValidationProps> = ({ operator, member, onSuccess, onError }) => {
const { t } = useTranslation('admin');
// list of proof of identity type
const [proofOfIdentityTypes, setProofOfIdentityTypes] = useState<Array<ProofOfIdentityType>>([]);
const [proofOfIdentityFiles, setProofOfIdentityFiles] = useState<Array<ProofOfIdentityFile>>([]);
const [modalIsOpen, setModalIsOpen] = useState<boolean>(false);
// get groups
useEffect(() => {
ProofOfIdentityTypeAPI.index({ group_id: member.group_id }).then(tData => {
setProofOfIdentityTypes(tData);
});
ProofOfIdentityFileAPI.index({ user_id: member.id }).then(fData => {
setProofOfIdentityFiles(fData);
});
}, []);
const getProofOfIdentityFileByType = (proofOfIdentityTypeId: number): ProofOfIdentityFile => {
return _.find<ProofOfIdentityFile>(proofOfIdentityFiles, { proof_of_identity_type_id: proofOfIdentityTypeId });
};
/**
* Check if the current collection of proof of identity types is empty or not.
*/
const hasProofOfIdentityTypes = (): boolean => {
return proofOfIdentityTypes.length > 0;
};
const getProofOfIdentityFileUrl = (poifId: number): string => {
return `/api/proof_of_identity_files/${poifId}/download`;
};
const openProofOfIdentityRefusalModal = (): void => {
setModalIsOpen(true);
};
const toggleModal = (): void => {
setModalIsOpen(false);
};
const saveProofOfIdentityRefusalOnSuccess = (message: string): void => {
setModalIsOpen(false);
onSuccess(message);
};
return (
<div>
<section className="panel panel-default bg-light m-lg col-sm-12 col-md-12 col-lg-7">
<h3>{t('app.admin.members_edit.proof_of_identity_files')}</h3>
<p className="text-black font-sbold">{t('app.admin.members_edit.find_below_the_proof_of_identity_files')}</p>
{proofOfIdentityTypes.map((poit: ProofOfIdentityType) => {
return (
<div key={poit.id} className="m-b">
<div className="m-b-xs">{poit.name}</div>
{getProofOfIdentityFileByType(poit.id) && (
<a href={getProofOfIdentityFileUrl(getProofOfIdentityFileByType(poit.id).id)} target="_blank" rel="noreferrer">
<span className="m-r">{getProofOfIdentityFileByType(poit.id).attachment}</span>
<i className="fa fa-download"></i>
</a>
)}
{!getProofOfIdentityFileByType(poit.id) && (
<div className="text-danger">{t('app.admin.members_edit.to_complete')}</div>
)}
</div>
);
})}
</section>
{hasProofOfIdentityTypes() && !member.validated_at && (
<section className="panel panel-default bg-light m-t-lg col-sm-12 col-md-12 col-lg-4">
<h3>{t('app.admin.members_edit.refuse_proof_of_identity_files')}</h3>
<p className="text-black">{t('app.admin.members_edit.refuse_proof_of_identity_files_info')}</p>
<button type="button" className="btn btn-warning m-b m-t" onClick={openProofOfIdentityRefusalModal}>{t('app.admin.members_edit.proof_of_identity_refusal')}</button>
<ProofOfIdentityRefusalModal
isOpen={modalIsOpen}
proofOfIdentityTypes={proofOfIdentityTypes}
toggleModal={toggleModal}
operator={operator}
member={member}
onError={onError}
onSuccess={saveProofOfIdentityRefusalOnSuccess}/>
</section>
)}
</div>
);
};
const ProofOfIdentityValidationWrapper: React.FC<ProofOfIdentityValidationProps> = ({ operator, member, onSuccess, onError }) => {
return (
<Loader>
<ProofOfIdentityValidation operator={operator} member={member} onSuccess={onSuccess} onError={onError} />
</Loader>
);
};
Application.Components.component('proofOfIdentityValidation', react2angular(ProofOfIdentityValidationWrapper, ['operator', 'member', 'onSuccess', 'onError']));

View File

@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import Switch from 'react-switch';
import _ from 'lodash';
import { AxiosResponse } from 'axios';
import { useTranslation } from 'react-i18next';
import { SettingName } from '../../models/setting';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import SettingAPI from '../../api/setting';
import { Loader } from '../base/loader';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
interface BooleanSettingProps {
name: SettingName,
label: string,
className?: string,
hideSave?: boolean,
onChange?: (value: string) => void,
onBeforeSave?: (message: string) => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component allows to configure boolean value for a setting.
*/
export const BooleanSetting: React.FC<BooleanSettingProps> = ({ name, label, className, hideSave, onChange, onSuccess, onError, onBeforeSave }) => {
const { t } = useTranslation('admin');
const [value, setValue] = useState<boolean>(false);
// on component load, we retrieve the current value of the list from the API
useEffect(() => {
SettingAPI.get(name)
.then(res => {
setValue(res.value === 'true');
if (_.isFunction(onChange)) {
onChange(res.value === 'true' ? 'true' : 'false');
}
})
.catch(err => onError(err));
}, []);
/**
* Save the built string to the Setting API
*/
const updateSetting = () => {
SettingAPI.update(name, value ? 'true' : 'false')
.then(() => onSuccess(t('app.admin.settings.customization_of_SETTING_successfully_saved', { SETTING: t(`app.admin.settings.${name}`) })))
.catch(err => {
if (err.status === 304) return;
if (err.status === 423) {
onError(t('app.admin.settings.error_SETTING_locked', { SETTING: t(`app.admin.settings.${name}`) }));
return;
}
console.log(err);
onError(t('app.admin.settings.an_error_occurred_saving_the_setting'));
});
};
/**
* Callback triggered when the 'save' button is clicked.
* Save the built string to the Setting API
*/
const handleSave = () => {
if (_.isFunction(onBeforeSave)) {
const res = onBeforeSave({ value, name });
if (res && _.isFunction(res.then)) {
// res is a promise, wait for it before proceed
res.then((success: AxiosResponse) => {
if (success) updateSetting();
else setValue(false);
}, function () {
setValue(false);
});
} else {
if (res) updateSetting();
else setValue(false);
}
} else {
updateSetting();
}
};
/**
* Callback triggered when the 'switch' is changed.
*/
const handleChanged = (_value: boolean) => {
setValue(_value);
if (_.isFunction(onChange)) {
onChange(_value ? 'true' : 'false');
}
};
return (
<div className={`form-group ${className || ''}`}>
<label htmlFor={`setting-${name}`} className="control-label m-r">{label}</label>
<Switch checked={value} id={`setting-${name}}`} onChange={handleChanged} className="v-middle"></Switch>
{!hideSave && <FabButton className="btn btn-warning m-l" onClick={handleSave}>{t('app.admin.check_list_setting.save')}</FabButton> }
</div>
);
};
export const BooleanSettingWrapper: React.FC<BooleanSettingProps> = ({ onChange, onSuccess, onError, label, className, name, hideSave, onBeforeSave }) => {
return (
<Loader>
<BooleanSetting label={label} name={name} onError={onError} onSuccess={onSuccess} onChange={onChange} className={className} hideSave={hideSave} onBeforeSave={onBeforeSave} />
</Loader>
);
};
Application.Components.component('booleanSetting', react2angular(BooleanSettingWrapper, ['className', 'name', 'label', 'onChange', 'onSuccess', 'onError', 'onBeforeSave']));

View File

@ -1,5 +1,6 @@
import React, { BaseSyntheticEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import _ from 'lodash';
import { SettingName } from '../../models/setting';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
@ -13,8 +14,11 @@ interface CheckListSettingProps {
name: SettingName,
label: string,
className?: string,
hideSave?: boolean,
defaultValue?: string,
// availableOptions must be like this [['option1', 'label 1'], ['option2', 'label 2']]
availableOptions: Array<Array<string>>,
onChange?: (value: string) => void,
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
@ -23,7 +27,7 @@ interface CheckListSettingProps {
* This component allows to configure multiples values for a setting, like a check list.
* The result is stored as a string, composed of the checked values, e.g. 'option1,option2'
*/
const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, className, availableOptions, onSuccess, onError }) => {
export const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, hideSave, defaultValue, className, availableOptions, onChange, onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [value, setValue] = useState<string>(null);
@ -31,7 +35,13 @@ const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, classN
// on component load, we retrieve the current value of the list from the API
useEffect(() => {
SettingAPI.get(name)
.then(res => setValue(res.value))
.then(res => {
const value = res.value === null && defaultValue ? defaultValue : res.value;
setValue(value);
if (_.isFunction(onChange)) {
onChange(value);
}
})
.catch(err => onError(err));
}, []);
@ -45,9 +55,16 @@ const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, classN
let newValue = value ? `${value},` : '';
newValue += option;
setValue(newValue);
if (_.isFunction(onChange)) {
onChange(newValue);
}
} else {
const regex = new RegExp(`,?${option}`, 'g');
setValue(value.replace(regex, ''));
const newValue = value.replace(regex, '');
setValue(newValue);
if (_.isFunction(onChange)) {
onChange(newValue);
}
}
};
};
@ -76,15 +93,15 @@ const CheckListSetting: React.FC<CheckListSettingProps> = ({ name, label, classN
<input id={`setting-${name}-${option[0]}`} type="checkbox" checked={isChecked(option[0])} onChange={toggleCheckbox(option[0])} />
<label htmlFor={`setting-${name}-${option[0]}`}>{option[1]}</label>
</div>)}
<FabButton className="save" onClick={handleSave}>{t('app.admin.check_list_setting.save')}</FabButton>
{!hideSave && <FabButton className="save" onClick={handleSave}>{t('app.admin.check_list_setting.save')}</FabButton>}
</div>
);
};
const CheckListSettingWrapper: React.FC<CheckListSettingProps> = ({ availableOptions, onSuccess, onError, label, className, name }) => {
export const CheckListSettingWrapper: React.FC<CheckListSettingProps> = ({ availableOptions, onSuccess, onError, label, className, name, hideSave, defaultValue, onChange }) => {
return (
<Loader>
<CheckListSetting availableOptions={availableOptions} label={label} name={name} onError={onError} onSuccess={onSuccess} className={className} />
<CheckListSetting availableOptions={availableOptions} label={label} name={name} onError={onError} onSuccess={onSuccess} className={className} hideSave={hideSave} defaultValue={defaultValue} onChange={onChange} />
</Loader>
);
};

View File

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingName } from '../../models/setting';
import { IApplication } from '../../models/application';
import { react2angular } from 'react2angular';
import SettingAPI from '../../api/setting';
import { Loader } from '../base/loader';
import { FabButton } from '../base/fab-button';
import { BooleanSetting } from './boolean-setting';
import { CheckListSetting } from './check-list-setting';
declare const Application: IApplication;
interface UserValidationSettingProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component allows to configure user validation required setting.
*/
const UserValidationSetting: React.FC<UserValidationSettingProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const [userValidationRequired, setUserValidationRequired] = useState<string>('false');
const userValidationRequiredListDefault = ['subscription', 'machine', 'event', 'space', 'training', 'pack'];
const [userValidationRequiredList, setUserValidationRequiredList] = useState<string>(null);
const userValidationRequiredOptions = userValidationRequiredListDefault.map(l => {
return [l, t(`app.admin.settings.compte.user_validation_required_list.${l}`)];
});
/**
* Save the built string to the Setting API
*/
const updateSetting = (name: SettingName, value: string) => {
SettingAPI.update(name, value)
.then(() => {
if (name === SettingName.UserValidationRequired) {
onSuccess(t('app.admin.settings.customization_of_SETTING_successfully_saved', { SETTING: t(`app.admin.settings.compte.${name}`) }));
}
}).catch(err => {
if (err.status === 304) return;
if (err.status === 423) {
if (name === SettingName.UserValidationRequired) {
onError(t('app.admin.settings.error_SETTING_locked', { SETTING: t(`app.admin.settings.compte.${name}`) }));
}
return;
}
console.log(err);
onError(t('app.admin.settings.an_error_occurred_saving_the_setting'));
});
};
/**
* Callback triggered when the 'save' button is clicked.
*/
const handleSave = () => {
updateSetting(SettingName.UserValidationRequired, userValidationRequired);
if (userValidationRequiredList !== null) {
if (userValidationRequired === 'true') {
updateSetting(SettingName.UserValidationRequiredList, userValidationRequiredList);
} else {
updateSetting(SettingName.UserValidationRequiredList, null);
}
}
};
return (
<div className="user-validation-setting">
<BooleanSetting name={SettingName.UserValidationRequired}
label={t('app.admin.settings.compte.user_validation_required_option_label')}
hideSave={true}
onChange={setUserValidationRequired}
onSuccess={onSuccess}
onError={onError}>
</BooleanSetting>
{userValidationRequired === 'true' &&
<div>
<h4>{t('app.admin.settings.compte.user_validation_required_list_title')}</h4>
<p>
{t('app.admin.settings.compte.user_validation_required_list_info')}
</p>
<p className="alert alert-warning">
{t('app.admin.settings.compte.user_validation_required_list_other_info')}
</p>
<CheckListSetting name={SettingName.UserValidationRequiredList}
label=""
availableOptions={userValidationRequiredOptions}
defaultValue={userValidationRequiredListDefault.join(',')}
hideSave={true}
onChange={setUserValidationRequiredList}
onSuccess={onSuccess}
onError={onError}>
</CheckListSetting>
</div>
}
<FabButton className="btn btn-warning m-t" onClick={handleSave}>{t('app.admin.check_list_setting.save')}</FabButton>
</div>
);
};
const UserValidationSettingWrapper: React.FC<UserValidationSettingProps> = ({ onSuccess, onError }) => {
return (
<Loader>
<UserValidationSetting onError={onError} onSuccess={onSuccess} />
</Loader>
);
};
Application.Components.component('userValidationSetting', react2angular(UserValidationSettingWrapper, ['onSuccess', 'onError']));

View File

@ -0,0 +1,79 @@
import React, { useState, useReducer } from 'react';
import { FormState, UseFormRegister, UseFormSetValue } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { User } from '../../models/user';
import { SocialNetwork } from '../../models/social-network';
import Icons from '../../../../images/social-icons.svg';
import { FormInput } from '../form/form-input';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
interface EditSocialsProps<TFieldValues> {
register: UseFormRegister<TFieldValues>,
setValue: UseFormSetValue<User>,
networks: SocialNetwork[],
formState: FormState<TFieldValues>,
disabled: boolean|((id: string) => boolean),
}
export const EditSocials = <TFieldValues extends FieldValues>({ register, setValue, networks, formState, disabled }: EditSocialsProps<TFieldValues>) => {
const { t } = useTranslation('shared');
// regular expression to validate the the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const initSelectedNetworks = networks.filter(el => !['', null, undefined].includes(el.url));
const [selectedNetworks, setSelectedNetworks] = useState(initSelectedNetworks);
const selectNetwork = (network) => {
setSelectedNetworks([...selectedNetworks, network]);
};
const reducer = (state, action) => {
switch (action.type) {
case 'delete':
setSelectedNetworks(selectedNetworks.filter(el => el !== action.payload.network));
setValue(action.payload.field, '');
return state.map(el => el === action.payload.network
? { ...el, url: '' }
: el);
case 'update':
return state.map(el => el === action.payload
? { ...el, url: action.payload.url }
: el);
default:
return state;
}
};
const [userNetworks, dispatch] = useReducer(reducer, networks);
return (
<>
<div className='social-icons'>
{userNetworks.map((network, index) =>
!selectedNetworks.includes(network) && <img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
{userNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<FormInput key={index}
id={`profile_attributes.${network.name}`}
register={register}
rules= {{
pattern: {
value: urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}
formState={formState}
defaultValue={network.url}
label={network.name}
disabled={disabled}
placeholder={t('app.shared.text_editor.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
addOn={<Trash size={16} />}
addOnAction={() => dispatch({ type: 'delete', payload: { network, field: `profile_attributes.${network.name}` } })} />
)}
</div>}
</>
);
};

View File

@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { FormInput } from '../form/form-input';
import SettingAPI from '../../api/setting';
import { supportedNetworks } from '../../models/social-network';
import { IApplication } from '../../models/application';
import { Loader } from '../base/loader';
import { react2angular } from 'react2angular';
import { SettingName } from '../../models/setting';
import Icons from '../../../../images/social-icons.svg';
import { Trash } from 'phosphor-react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
declare const Application: IApplication;
interface FabSocialsProps {
show: boolean,
onError: (message: string) => void,
onSuccess: (message: string) => void
}
export const FabSocials: React.FC<FabSocialsProps> = ({ show = false, onError, onSuccess }) => {
const { t } = useTranslation('shared');
// regular expression to validate the the input fields
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z\d.]{2,30})([/\w .-]*)*\/?$/;
const { handleSubmit, register, setValue, formState } = useForm();
const settingsList = supportedNetworks.map(el => el as SettingName);
const [fabNetworks, setFabNetworks] = useState([]);
const [selectedNetworks, setSelectedNetworks] = useState([]);
useEffect(() => {
SettingAPI.query(settingsList).then(res => {
setFabNetworks(Array.from(res, ([name, url]) => ({ name, url })));
}).catch(error => console.error(error));
}, []);
useEffect(() => {
setSelectedNetworks(fabNetworks.filter(el => el.url !== ''));
}, [fabNetworks]);
const onSubmit = (data) => {
const updatedNetworks = new Map<SettingName, string>();
Object.keys(data).forEach(key => updatedNetworks.set(key as SettingName, data[key]));
SettingAPI.bulkUpdate(updatedNetworks).then(res => {
const errorResults = Array.from(res.values()).filter(item => !item.status);
if (errorResults.length > 0) {
onError(t('app.shared.fab_socials.networks_update_error'));
} else {
onSuccess(t('app.shared.fab_socials.networks_update_success'));
}
});
};
const selectNetwork = (network) => {
setSelectedNetworks([...selectedNetworks, network]);
};
const remove = (network) => {
setSelectedNetworks(selectedNetworks.filter(el => el !== network));
setValue(network.name, '');
};
return (
<>{show
? <div className='social-icons'>
{fabNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<a key={index} href={network.url} target='_blank' rel="noreferrer">
<img src={`${Icons}#${network.name}`}></img>
</a>
)}
</div>
: <form onSubmit={handleSubmit(onSubmit)}>
<div className='social-icons'>
{fabNetworks.map((network, index) =>
!selectedNetworks.includes(network) &&
<img key={index} src={`${Icons}#${network.name}`} onClick={() => selectNetwork(network)}></img>
)}
</div>
{selectNetwork.length && <div className='social-inputs'>
{fabNetworks.map((network, index) =>
selectedNetworks.includes(network) &&
<FormInput id={network.name}
key={index}
register={register}
rules={{
pattern: {
value: urlRegex,
message: t('app.shared.user_profile_form.website_invalid')
}
}}
formState={formState}
defaultValue={network.url}
label={network.name}
placeholder={t('app.shared.fab_socials.url_placeholder')}
icon={<img src={`${Icons}#${network.name}`}></img>}
addOn={<Trash size={16} />}
addOnAction={() => remove(network)} />
)}
</div>}
<FabButton type='submit'
className='btn-warning'>
{t('app.shared.buttons.save')}
</FabButton>
</form>
}</>
);
};
const FabSocialsWrapper: React.FC<FabSocialsProps> = (props) => {
return (
<Loader>
<FabSocials {...props} />
</Loader>
);
};
Application.Components.component('fabSocials', react2angular(FabSocialsWrapper, ['show', 'onError', 'onSuccess']));

View File

@ -10,6 +10,7 @@ import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
import LocalPaymentAPI from '../../api/local-payment';
import { PaymentMethod } from '../../models/payment';
import { TDateISO } from '../../typings/date-iso';
declare const Application: IApplication;
@ -26,7 +27,6 @@ interface FreeExtendModalProps {
* Modal dialog shown to extend the current subscription of a customer, for free
*/
const FreeExtendModal: React.FC<FreeExtendModalProps> = ({ isOpen, toggleModal, subscription, customerId, onError, onSuccess }) => {
// we do not render the modal if the subscription was not provided
if (!subscription) return null;
@ -47,7 +47,7 @@ const FreeExtendModal: React.FC<FreeExtendModalProps> = ({ isOpen, toggleModal,
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const formatDateTime = (date: TDateISO): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};

View File

@ -18,6 +18,7 @@ import { PaymentScheduleSummary } from '../payment-schedule/payment-schedule-sum
import { PaymentSchedule } from '../../models/payment-schedule';
import { LocalPaymentModal } from '../payment/local-payment/local-payment-modal';
import { User } from '../../models/user';
import { TDateISO } from '../../typings/date-iso';
declare const Application: IApplication;
@ -83,7 +84,7 @@ const RenewModal: React.FC<RenewModalProps> = ({ isOpen, toggleModal, subscripti
/**
* Return the formatted localized date for the given date
*/
const formatDateTime = (date: Date): string => {
const formatDateTime = (date: Date|TDateISO): string => {
return t('app.admin.free_extend_modal.DATE_TIME', { DATE: FormatLib.date(date), TIME: FormatLib.time(date) });
};

Some files were not shown because too many files have changed in this diff Show More