mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-11-29 10:24:20 +01:00
Merge branch 'v5.4' into dev
This commit is contained in:
commit
1b35dfcc0f
@ -31,6 +31,9 @@ imports
|
||||
# accounting archives
|
||||
accounting
|
||||
|
||||
# Proof of identity files
|
||||
proof_of_identity_files
|
||||
|
||||
# Development files
|
||||
Vagrantfile
|
||||
provision
|
||||
|
@ -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
3
.gitignore
vendored
@ -46,6 +46,9 @@
|
||||
# Archives of closed accounting periods
|
||||
/accounting/*
|
||||
|
||||
# Proof of identity files
|
||||
/proof_of_identity_files/*
|
||||
|
||||
.DS_Store
|
||||
|
||||
.vagrant
|
||||
|
38
CHANGELOG.md
38
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
3
Gemfile
3
Gemfile
@ -69,6 +69,7 @@ gem 'devise', '>= 4.6.0'
|
||||
|
||||
gem 'omniauth', '~> 1.9.0'
|
||||
gem 'omniauth-oauth2'
|
||||
gem 'omniauth_openid_connect'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 0.1'
|
||||
|
||||
gem 'rolify'
|
||||
@ -141,4 +142,4 @@ gem 'icalendar'
|
||||
gem 'tzinfo-data'
|
||||
|
||||
# compilation of dynamic stylesheets (home page & theme)
|
||||
gem 'sassc'
|
||||
gem 'sassc', '= 2.1.0'
|
||||
|
67
Gemfile.lock
67
Gemfile.lock
@ -50,6 +50,7 @@ GEM
|
||||
tzinfo (~> 1.1)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
afm (0.2.2)
|
||||
ansi (1.5.0)
|
||||
api-pagination (4.8.2)
|
||||
@ -57,12 +58,14 @@ GEM
|
||||
rails (>= 4.1)
|
||||
arel (9.0.0)
|
||||
ast (2.4.0)
|
||||
attr_required (1.0.1)
|
||||
awesome_print (1.8.0)
|
||||
axiom-types (0.1.1)
|
||||
descendants_tracker (~> 0.0.4)
|
||||
ice_nine (~> 0.11.0)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
bcrypt (3.1.13)
|
||||
bindata (2.4.10)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.4.6)
|
||||
msgpack (~> 1.0)
|
||||
@ -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)
|
||||
|
@ -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]]]])
|
||||
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
|
||||
|
@ -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: []])
|
||||
|
||||
|
@ -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
|
||||
|
50
app/controllers/api/profile_custom_fields_controller.rb
Normal file
50
app/controllers/api/profile_custom_fields_controller.rb
Normal 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
|
54
app/controllers/api/proof_of_identity_files_controller.rb
Normal file
54
app/controllers/api/proof_of_identity_files_controller.rb
Normal 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
|
32
app/controllers/api/proof_of_identity_refusals_controller.rb
Normal file
32
app/controllers/api/proof_of_identity_refusals_controller.rb
Normal 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
|
50
app/controllers/api/proof_of_identity_types_controller.rb
Normal file
50
app/controllers/api/proof_of_identity_types_controller.rb
Normal 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
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
def after_confirmation_path_for(_resource_name, resource)
|
||||
signed_in_root_path(resource)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -73,7 +73,7 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||
logger.error 'user already linked'
|
||||
redirect_to root_url, alert: t('omniauth.this_account_is_already_linked_to_an_user_of_the_platform', NAME: active_provider.name)
|
||||
rescue StandardError => e
|
||||
logger.unknown "an expected error occurred: #{e}"
|
||||
logger.error "an expected error occurred: #{e}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
@ -85,7 +85,8 @@ require('src/javascript/plugins.js.erb');
|
||||
|
||||
function importAll (r) { r.keys().forEach(r); }
|
||||
|
||||
importAll(require.context('src/javascript/components/', true, /.*/));
|
||||
// we do not include markdown files (*.md)
|
||||
importAll(require.context('src/javascript/components/', true, /^.+\.(?!md).+/));
|
||||
importAll(require.context('src/javascript/controllers/', true, /.*/));
|
||||
importAll(require.context('src/javascript/services/', true, /.*/));
|
||||
importAll(require.context('src/javascript/directives/', true, /.*/));
|
||||
|
1
app/frontend/images/github.svg
Normal file
1
app/frontend/images/github.svg
Normal 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 |
1
app/frontend/images/icons.svg
Normal file
1
app/frontend/images/icons.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 10 KiB |
73
app/frontend/images/social-icons.svg
Normal file
73
app/frontend/images/social-icons.svg
Normal 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 |
44
app/frontend/src/javascript/api/auth-provider.ts
Normal file
44
app/frontend/src/javascript/api/auth-provider.ts
Normal 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;
|
||||
}
|
||||
}
|
23
app/frontend/src/javascript/api/authentication.ts
Normal file
23
app/frontend/src/javascript/api/authentication.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
9
app/frontend/src/javascript/api/clients/sso-client.ts
Normal file
9
app/frontend/src/javascript/api/clients/sso-client.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
function client (host: string): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: host
|
||||
});
|
||||
}
|
||||
|
||||
export default client;
|
13
app/frontend/src/javascript/api/external/sso.ts
vendored
Normal file
13
app/frontend/src/javascript/api/external/sso.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
import ssoClient from '../clients/sso-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { OpenIdConfiguration } from '../../models/sso';
|
||||
|
||||
export default class SsoClient {
|
||||
/**
|
||||
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
*/
|
||||
static async openIdConfiguration (host: string): Promise<OpenIdConfiguration> {
|
||||
const res: AxiosResponse<OpenIdConfiguration> = await ssoClient(host).get('.well-known/openid-configuration');
|
||||
return res?.data;
|
||||
}
|
||||
}
|
47
app/frontend/src/javascript/api/member.ts
Normal file
47
app/frontend/src/javascript/api/member.ts
Normal 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;
|
||||
}
|
||||
}
|
30
app/frontend/src/javascript/api/profile-custom-field.ts
Normal file
30
app/frontend/src/javascript/api/profile-custom-field.ts
Normal 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;
|
||||
}
|
||||
}
|
36
app/frontend/src/javascript/api/proof-of-identity-file.ts
Normal file
36
app/frontend/src/javascript/api/proof-of-identity-file.ts
Normal 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('&');
|
||||
}
|
||||
}
|
21
app/frontend/src/javascript/api/proof-of-identity-refusal.ts
Normal file
21
app/frontend/src/javascript/api/proof-of-identity-refusal.ts
Normal 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('&');
|
||||
}
|
||||
}
|
36
app/frontend/src/javascript/api/proof-of-identity-type.ts
Normal file
36
app/frontend/src/javascript/api/proof-of-identity-type.ts
Normal 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('&');
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
10
app/frontend/src/javascript/api/tag.ts
Normal file
10
app/frontend/src/javascript/api/tag.ts
Normal 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;
|
||||
}
|
||||
}
|
16
app/frontend/src/javascript/api/training.ts
Normal file
16
app/frontend/src/javascript/api/training.ts
Normal 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('&');
|
||||
}
|
||||
}
|
@ -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', []);
|
||||
|
17
app/frontend/src/javascript/components/README.md
Normal file
17
app/frontend/src/javascript/components/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# components
|
||||
|
||||
This directory is holding the components built with [React](https://reactjs.org/).
|
||||
|
||||
During the migration phase, these components may be included in [the legacy angularJS app](../../templates) using [react2angular](https://github.com/coatue-oss/react2angular).
|
||||
|
||||
These components must be written using the following conventions:
|
||||
- The component name must be in CamelCase.
|
||||
- The component must be exported as a named export (no `export default`).
|
||||
- A component `FooBar` must have a `className="foo-bar"` attribute on its top-level element.
|
||||
- The stylesheet associated with the component must be located in `app/frontend/src/stylesheets/modules/same-directory-structure/foo-bar.scss`.
|
||||
- All methods in the component must be commented with a comment block.
|
||||
- Other constants and variables must be commented with an inline block.
|
||||
- Depending on if we want to use the `<Suspense>` wrapper or not, we can export the component directly or wrap it in a `<Loader>` wrapper.
|
||||
- When a component is used in angularJS, the wrapper is required. The component must be named like `const Foo` (no export if not used in React) and must have a `const FooWrapper` at the end of its file, which wraps the component in a `<Loader>`.
|
||||
- Translations must be grouped per component. For example, the `FooBar` component must have its translations in the `config/locales/app.$SCOPE.en.yml` file, under the `foo_bar` key.
|
||||
|
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormInput } from '../form/form-input';
|
||||
|
||||
export interface BooleanMappingFormProps<TFieldValues> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
fieldMappingId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial form to map an internal boolean field to an external API providing a string value.
|
||||
*/
|
||||
export const BooleanMappingForm = <TFieldValues extends FieldValues>({ register, fieldMappingId }: BooleanMappingFormProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
return (
|
||||
<div className="boolean-mapping-form">
|
||||
<h4>{t('app.admin.authentication.boolean_mapping_form.mappings')}</h4>
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.true_value`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.boolean_mapping_form.true_value')} />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.false_value`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.boolean_mapping_form.false_value')} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UseFormRegister, useFieldArray, ArrayPath, useWatch, Path } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import AuthProviderAPI from '../../api/auth-provider';
|
||||
import { AuthenticationProviderMapping, MappingFields, mappingType, ProvidableType } from '../../models/authentication-provider';
|
||||
import { Control, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { TypeMappingModal } from './type-mapping-modal';
|
||||
import { useImmer } from 'use-immer';
|
||||
import { Oauth2DataMappingForm } from './oauth2-data-mapping-form';
|
||||
import { OpenidConnectDataMappingForm } from './openid-connect-data-mapping-form';
|
||||
|
||||
export interface DataMappingFormProps<TFieldValues, TContext extends object> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
providerType: ProvidableType,
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
currentFormValues: Array<AuthenticationProviderMapping>,
|
||||
}
|
||||
|
||||
type selectModelFieldOption = { value: string, label: string };
|
||||
|
||||
/**
|
||||
* Partial form to define the mapping of the data between the API of the authentication provider and the application internals.
|
||||
*/
|
||||
export const DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, providerType, setValue, currentFormValues }: DataMappingFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
const [dataMapping, setDataMapping] = useState<MappingFields>(null);
|
||||
const [isOpenTypeMappingModal, updateIsOpenTypeMappingModal] = useImmer<Map<number, boolean>>(new Map());
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes' as ArrayPath<TFieldValues> });
|
||||
const output = useWatch({ name: 'auth_provider_mappings_attributes' as Path<TFieldValues>, control });
|
||||
|
||||
/**
|
||||
* Build the list of available models for the data mapping
|
||||
*/
|
||||
const buildModelOptions = (): Array<selectModelFieldOption> => {
|
||||
if (!dataMapping) return [];
|
||||
|
||||
return Object.keys(dataMapping).map(model => {
|
||||
return {
|
||||
label: model,
|
||||
value: model
|
||||
};
|
||||
}) || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the list of fields of the current model for the data mapping
|
||||
*/
|
||||
const buildFieldOptions = (formData: Array<TFieldValues>, index: number): Array<selectModelFieldOption> => {
|
||||
if (!dataMapping) return [];
|
||||
|
||||
return dataMapping[getModel(formData, index)]?.map(field => {
|
||||
return {
|
||||
label: field[0],
|
||||
value: field[0]
|
||||
};
|
||||
}) || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the name of the modal for the given index, in the current data-mapping form
|
||||
*/
|
||||
const getModel = (formData: Array<TFieldValues>, index: number): string => {
|
||||
return formData ? formData[index]?.local_model : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the name of the field for the given index, in the current data-mapping form
|
||||
*/
|
||||
const getField = (formData: Array<TFieldValues>, index: number): string => {
|
||||
return formData ? formData[index]?.local_field : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the type of data expected for the given index, in the current data-mapping form
|
||||
*/
|
||||
const getDataType = (formData: Array<TFieldValues>, index: number): mappingType => {
|
||||
const model = getModel(formData, index);
|
||||
const field = getField(formData, index);
|
||||
if (model && field && dataMapping) {
|
||||
return dataMapping[model]?.find(f => f[0] === field)?.[1];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/closes the "edit type mapping" modal dialog for the given mapping index
|
||||
*/
|
||||
const toggleTypeMappingModal = (index: number): () => void => {
|
||||
return () => {
|
||||
updateIsOpenTypeMappingModal(draft => draft.set(index, !draft.get(index)));
|
||||
};
|
||||
};
|
||||
|
||||
// fetch the mapping data from the API on mount
|
||||
useEffect(() => {
|
||||
AuthProviderAPI.mappingFields().then((data) => {
|
||||
setDataMapping(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="data-mapping-form array-mapping-form">
|
||||
<h4>{t('app.admin.authentication.data_mapping_form.define_the_fields_mapping')}</h4>
|
||||
<div className="mapping-actions">
|
||||
<FabButton
|
||||
icon={<i className="fa fa-plus"/>}
|
||||
onClick={() => append({})}>
|
||||
{t('app.admin.authentication.data_mapping_form.add_a_match')}
|
||||
</FabButton>
|
||||
</div>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="mapping-item">
|
||||
<div className="inputs">
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.id`} register={register} type="hidden" />
|
||||
<div className="local-data">
|
||||
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_model`}
|
||||
control={control} rules={{ required: true }}
|
||||
options={buildModelOptions()}
|
||||
label={t('app.admin.authentication.data_mapping_form.model')}/>
|
||||
<FormSelect id={`auth_provider_mappings_attributes.${index}.local_field`}
|
||||
options={buildFieldOptions(output, index)}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.data_mapping_form.field')} />
|
||||
</div>
|
||||
<div className="remote-data">
|
||||
{providerType === 'OAuth2Provider' && <Oauth2DataMappingForm register={register} control={control} index={index} />}
|
||||
{providerType === 'OpenIdConnectProvider' && <OpenidConnectDataMappingForm register={register}
|
||||
index={index}
|
||||
setValue={setValue}
|
||||
currentFormValues={currentFormValues} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<FabButton icon={<i className="fa fa-random" />}
|
||||
onClick={toggleTypeMappingModal(index)}
|
||||
disabled={getField(output, index) === undefined}
|
||||
tooltip={t('app.admin.authentication.data_mapping_form.data_mapping')} />
|
||||
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
|
||||
<TypeMappingModal model={getModel(output, index)}
|
||||
field={getField(output, index)}
|
||||
type={getDataType(output, index)}
|
||||
isOpen={isOpenTypeMappingModal.get(index)}
|
||||
toggleModal={toggleTypeMappingModal(index)}
|
||||
control={control} register={register}
|
||||
fieldMappingId={index} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
|
||||
interface DatabaseFormProps<TFieldValues> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial form to fill the settings for a new/existing database provider.
|
||||
*/
|
||||
export const DatabaseForm = <TFieldValues extends FieldValues>({ register }: DatabaseFormProps<TFieldValues>) => {
|
||||
return (
|
||||
<div className="database-form">
|
||||
<FormInput id="providable_attributes.id"
|
||||
register={register}
|
||||
type="hidden" />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
|
||||
export interface DateMappingFormProps<TFieldValues, TContext extends object> {
|
||||
control: Control<TFieldValues, TContext>,
|
||||
fieldMappingId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial form for mapping an internal date field to an external API.
|
||||
*/
|
||||
export const DateMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ control, fieldMappingId }: DateMappingFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
// available date formats
|
||||
const dateFormats = [
|
||||
{
|
||||
label: 'ISO 8601',
|
||||
value: 'iso8601'
|
||||
},
|
||||
{
|
||||
label: 'RFC 2822',
|
||||
value: 'rfc2822'
|
||||
},
|
||||
{
|
||||
label: 'RFC 3339',
|
||||
value: 'rfc3339'
|
||||
},
|
||||
{
|
||||
label: 'Timestamp (s)',
|
||||
value: 'timestamp-s'
|
||||
},
|
||||
{
|
||||
label: 'Timestamp (ms)',
|
||||
value: 'timestamp-ms'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="date-mapping-form">
|
||||
<h4>{t('app.admin.authentication.date_mapping_form.input_format')}</h4>
|
||||
<FormSelect id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.format`}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
options={dateFormats}
|
||||
label={t('app.admin.authentication.date_mapping_form.date_format')} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FormInput } from '../form/form-input';
|
||||
|
||||
export interface IntegerMappingFormProps<TFieldValues, TContext extends object> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
fieldMappingId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial for to map an internal integer field to an external API providing a string value.
|
||||
*/
|
||||
export const IntegerMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: IntegerMappingFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
|
||||
|
||||
return (
|
||||
<div className="integer-mapping-form array-mapping-form">
|
||||
<h4>{t('app.admin.authentication.integer_mapping_form.mappings')}</h4>
|
||||
<div className="mapping-actions">
|
||||
<FabButton
|
||||
icon={<i className="fa fa-plus" />}
|
||||
onClick={() => append({})} />
|
||||
</div>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="mapping-item">
|
||||
<div className="inputs">
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.integer_mapping_form.mapping_from')} />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
|
||||
register={register}
|
||||
type="number"
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.integer_mapping_form.mapping_to')} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { FormSelect } from '../form/form-select';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Oauth2DataMappingFormProps<TFieldValues, TContext extends object> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
index: number,
|
||||
}
|
||||
|
||||
export const Oauth2DataMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, index }: Oauth2DataMappingFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
return (
|
||||
<div className="oauth2-data-mapping-form">
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
placeholder="/api/resource..."
|
||||
label={t('app.admin.authentication.oauth2_data_mapping_form.api_endpoint_url')} />
|
||||
<FormSelect id={`auth_provider_mappings_attributes.${index}.api_data_type`}
|
||||
options={[{ label: 'JSON', value: 'json' }]}
|
||||
control={control} rules={{ required: true }}
|
||||
label={t('app.admin.authentication.oauth2_data_mapping_form.api_type')} />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
placeholder="field_name..."
|
||||
tooltip={<HtmlTranslate trKey="app.admin.authentication.oauth2_data_mapping_form.api_field_help_html" />}
|
||||
label={t('app.admin.authentication.oauth2_data_mapping_form.api_field')} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabOutputCopy } from '../base/fab-output-copy';
|
||||
|
||||
interface Oauth2FormProps<TFieldValues> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
strategyName?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial form to fill the OAuth2 settings for a new/existing authentication provider.
|
||||
*/
|
||||
export const Oauth2Form = <TFieldValues extends FieldValues>({ register, strategyName }: Oauth2FormProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
// regular expression to validate the input fields
|
||||
const endpointRegex = /^\/?([-._~:?#[\]@!$&'()*+,;=%\w]+\/?)*$/;
|
||||
const urlRegex = /^(https?:\/\/)([\da-z.-]+)\.([-a-z0-9.]{2,30})([/\w .-]*)*\/?$/;
|
||||
|
||||
/**
|
||||
* Build the callback URL, based on the strategy name.
|
||||
*/
|
||||
const buildCallbackUrl = (): string => {
|
||||
return `${window.location.origin}/users/auth/${strategyName}/callback`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="oauth2-form">
|
||||
<hr/>
|
||||
<FabOutputCopy text={buildCallbackUrl()} label={t('app.admin.authentication.oauth2_form.authorization_callback_url')} />
|
||||
<FormInput id="providable_attributes.base_url"
|
||||
register={register}
|
||||
placeholder="https://sso.example.net..."
|
||||
label={t('app.admin.authentication.oauth2_form.common_url')}
|
||||
rules={{ required: true, pattern: urlRegex }} />
|
||||
<FormInput id="providable_attributes.authorization_endpoint"
|
||||
register={register}
|
||||
placeholder="/oauth2/auth..."
|
||||
label={t('app.admin.authentication.oauth2_form.authorization_endpoint')}
|
||||
rules={{ required: true, pattern: endpointRegex }} />
|
||||
<FormInput id="providable_attributes.token_endpoint"
|
||||
register={register}
|
||||
placeholder="/oauth2/token..."
|
||||
label={t('app.admin.authentication.oauth2_form.token_acquisition_endpoint')}
|
||||
rules={{ required: true, pattern: endpointRegex }} />
|
||||
<FormInput id="providable_attributes.profile_url"
|
||||
register={register}
|
||||
placeholder="https://exemple.net/user..."
|
||||
label={t('app.admin.authentication.oauth2_form.profile_edition_url')}
|
||||
tooltip={t('app.admin.authentication.oauth2_form.profile_edition_url_help')}
|
||||
rules={{ required: true, pattern: urlRegex }} />
|
||||
<FormInput id="providable_attributes.client_id"
|
||||
register={register}
|
||||
label={t('app.admin.authentication.oauth2_form.client_identifier')}
|
||||
rules={{ required: true }} />
|
||||
<FormInput id="providable_attributes.client_secret"
|
||||
register={register}
|
||||
label={t('app.admin.authentication.oauth2_form.client_secret')}
|
||||
rules={{ required: true }} />
|
||||
<FormInput id="providable_attributes.scopes" register={register}
|
||||
placeholder="profile,email..."
|
||||
label={t('app.admin.authentication.oauth2_form.scopes')} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Path, UseFormRegister } from 'react-hook-form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { FormInput } from '../form/form-input';
|
||||
import { HtmlTranslate } from '../base/html-translate';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UnpackNestedValue, UseFormSetValue } from 'react-hook-form/dist/types/form';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FieldPathValue } from 'react-hook-form/dist/types/path';
|
||||
import { AuthenticationProviderMapping } from '../../models/authentication-provider';
|
||||
|
||||
interface OpenidConnectDataMappingFormProps<TFieldValues> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
setValue: UseFormSetValue<TFieldValues>,
|
||||
currentFormValues: Array<AuthenticationProviderMapping>,
|
||||
index: number,
|
||||
}
|
||||
|
||||
export const OpenidConnectDataMappingForm = <TFieldValues extends FieldValues>({ register, setValue, currentFormValues, index }: OpenidConnectDataMappingFormProps<TFieldValues>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const standardConfiguration = {
|
||||
'user.uid': { api_field: 'sub' },
|
||||
'user.email': { api_field: 'email' },
|
||||
'user.username': { api_field: 'preferred_username' },
|
||||
'profile.first_name': { api_field: 'given_name' },
|
||||
'profile.last_name': { api_field: 'family_name' },
|
||||
'profile.avatar': { api_field: 'picture' },
|
||||
'profile.website': { api_field: 'website' },
|
||||
'profile.gender': { api_field: 'gender', transformation: { true_value: 'male', false_value: 'female' } },
|
||||
'profile.birthday': { api_field: 'birthdate', transformation: { format: 'iso8601' } },
|
||||
'profile.phone': { api_field: 'phone_number' },
|
||||
'profile.address': { api_field: 'address.formatted' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the data mapping according to the standard OpenID Connect specification
|
||||
*/
|
||||
const openIdStandardConfiguration = (): void => {
|
||||
const model = currentFormValues[index]?.local_model;
|
||||
const field = currentFormValues[index]?.local_field;
|
||||
const configuration = standardConfiguration[`${model}.${field}`];
|
||||
setValue(
|
||||
`auth_provider_mappings_attributes.${index}.api_field` as Path<TFieldValues>,
|
||||
configuration.api_field as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
if (configuration.transformation) {
|
||||
Object.keys(configuration.transformation).forEach((key) => {
|
||||
setValue(
|
||||
`auth_provider_mappings_attributes.${index}.transformation.${key}` as Path<TFieldValues>,
|
||||
configuration.transformation[key] as UnpackNestedValue<FieldPathValue<TFieldValues, Path<TFieldValues>>>
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="openid-connect-data-mapping-form">
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.api_endpoint`}
|
||||
type="hidden"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
defaultValue="user_info" />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.api_data_type`}
|
||||
type="hidden"
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
defaultValue="json" />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${index}.api_field`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
placeholder="claim..."
|
||||
tooltip={<HtmlTranslate trKey="app.admin.authentication.openid_connect_data_mapping_form.api_field_help_html" />}
|
||||
label={t('app.admin.authentication.openid_connect_data_mapping_form.api_field')} />
|
||||
<FabButton
|
||||
icon={<i className="fa fa-magic" />}
|
||||
className="auto-configure-button"
|
||||
onClick={openIdStandardConfiguration}
|
||||
tooltip={t('app.admin.authentication.openid_connect_data_mapping_form.openid_standard_configuration')} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,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>
|
||||
);
|
||||
};
|
@ -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']));
|
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { ArrayPath, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { FormInput } from '../form/form-input';
|
||||
|
||||
export interface StringMappingFormProps<TFieldValues, TContext extends object> {
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
fieldMappingId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial form to map an internal string field to an external API.
|
||||
*/
|
||||
export const StringMappingForm = <TFieldValues extends FieldValues, TContext extends object>({ register, control, fieldMappingId }: StringMappingFormProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'auth_provider_mappings_attributes_transformation_mapping' as ArrayPath<TFieldValues> });
|
||||
|
||||
return (
|
||||
<div className="string-mapping-form array-mapping-form">
|
||||
<h4>{t('app.admin.authentication.string_mapping_form.mappings')}</h4>
|
||||
<div className="mapping-actions">
|
||||
<FabButton
|
||||
icon={<i className="fa fa-plus" />}
|
||||
onClick={() => append({})} />
|
||||
</div>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="mapping-item">
|
||||
<div className="inputs">
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.from`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.string_mapping_form.mapping_from')} />
|
||||
<FormInput id={`auth_provider_mappings_attributes.${fieldMappingId}.transformation.mapping.${index}.to`}
|
||||
register={register}
|
||||
rules={{ required: true }}
|
||||
label={t('app.admin.authentication.string_mapping_form.mapping_to')} />
|
||||
</div>
|
||||
<div className="actions">
|
||||
<FabButton icon={<i className="fa fa-trash" />} onClick={() => remove(index)} className="delete-button" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { FabModal } from '../base/fab-modal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IntegerMappingForm } from './integer-mapping-form';
|
||||
import { UseFormRegister } from 'react-hook-form';
|
||||
import { Control } from 'react-hook-form/dist/types/form';
|
||||
import { FieldValues } from 'react-hook-form/dist/types/fields';
|
||||
import { mappingType } from '../../models/authentication-provider';
|
||||
import { BooleanMappingForm } from './boolean-mapping-form';
|
||||
import { DateMappingForm } from './date-mapping-form';
|
||||
import { StringMappingForm } from './string-mapping-form';
|
||||
|
||||
interface TypeMappingModalProps<TFieldValues, TContext extends object> {
|
||||
model: string,
|
||||
field: string,
|
||||
type: mappingType,
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
register: UseFormRegister<TFieldValues>,
|
||||
control: Control<TFieldValues, TContext>,
|
||||
fieldMappingId: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal dialog to display the expected type for the current data field.
|
||||
* Also allows to map the incoming data (from the authentication provider API) to the expected type/data.
|
||||
*
|
||||
* This component is intended to be used in a react-hook-form context.
|
||||
*/
|
||||
export const TypeMappingModal = <TFieldValues extends FieldValues, TContext extends object>({ model, field, type, isOpen, toggleModal, register, control, fieldMappingId }:TypeMappingModalProps<TFieldValues, TContext>) => {
|
||||
const { t } = useTranslation('admin');
|
||||
|
||||
return (
|
||||
<FabModal isOpen={isOpen}
|
||||
toggleModal={toggleModal}
|
||||
className="type-mapping-modal"
|
||||
title={t('app.admin.authentication.type_mapping_modal.data_mapping')}
|
||||
confirmButton={<i className="fa fa-check" />}
|
||||
onConfirm={toggleModal}>
|
||||
<span>{model} > {field} ({t('app.admin.authentication.type_mapping_modal.TYPE_expected', { TYPE: t(`app.admin.authentication.type_mapping_modal.types.${type}`) })})</span>
|
||||
{type === 'integer' && <IntegerMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
|
||||
{type === 'boolean' && <BooleanMappingForm register={register} fieldMappingId={fieldMappingId} />}
|
||||
{type === 'date' && <DateMappingForm control={control} fieldMappingId={fieldMappingId} />}
|
||||
{type === 'string' && <StringMappingForm register={register} control={control} fieldMappingId={fieldMappingId} />}
|
||||
</FabModal>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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
|
||||
|
8
app/frontend/src/javascript/components/form/README.md
Normal file
8
app/frontend/src/javascript/components/form/README.md
Normal file
@ -0,0 +1,8 @@
|
||||
# components/from
|
||||
|
||||
This directory is holding the inputs components for usage within forms controlled by [React-hook-form](https://react-hook-form.com/).
|
||||
|
||||
All these components must have [props](https://reactjs.org/docs/components-and-props.html) that inherits from [FormComponent](../models/form-component.ts)
|
||||
or from [FormControlledComponent](../models/form-component.ts).
|
||||
|
||||
Please look at the existing components for examples.
|
@ -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>
|
||||
);
|
||||
};
|
74
app/frontend/src/javascript/components/form/form-input.tsx
Normal file
74
app/frontend/src/javascript/components/form/form-input.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
78
app/frontend/src/javascript/components/form/form-select.tsx
Normal file
78
app/frontend/src/javascript/components/form/form-select.tsx
Normal 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>
|
||||
);
|
||||
};
|
50
app/frontend/src/javascript/components/form/form-switch.tsx
Normal file
50
app/frontend/src/javascript/components/form/form-switch.tsx
Normal 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>
|
||||
);
|
||||
};
|
126
app/frontend/src/javascript/components/group/change-group.tsx
Normal file
126
app/frontend/src/javascript/components/group/change-group.tsx
Normal 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']));
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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']));
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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']));
|
||||
|
@ -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');
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>)}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
@ -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']));
|
||||
|
@ -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']));
|
@ -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']));
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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']));
|
@ -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']));
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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']));
|
@ -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>}
|
||||
</>
|
||||
);
|
||||
};
|
122
app/frontend/src/javascript/components/socials/fab-socials.tsx
Normal file
122
app/frontend/src/javascript/components/socials/fab-socials.tsx
Normal 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']));
|
@ -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) });
|
||||
};
|
||||
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user