diff --git a/app/assets/javascripts/vue.js b/app/assets/javascripts/vue.js index 1394abd16d1c22a72ff984c8475972930003e9c2..f812291c676f39a69a6905da16ff171b60e1cf5c 100644 --- a/app/assets/javascripts/vue.js +++ b/app/assets/javascripts/vue.js @@ -1,2 +1,2 @@ -//= require vue/dist/vue.global.js +//= require vue/dist/vue.global.prod.js //= require vue-draggable-next/dist/vue-draggable-next.global.js diff --git a/app/assets/stylesheets/commons/_block.sass b/app/assets/stylesheets/commons/_block.sass index 573a390e9e1a82760d4a4ab3f93e03cfec8a4c83..4acd08cac2f9e8b41426a464b9c202efc055910c 100644 --- a/app/assets/stylesheets/commons/_block.sass +++ b/app/assets/stylesheets/commons/_block.sass @@ -1,5 +1,5 @@ [v-cloak] - form + form, .app-form display: none [data-v-app] .spinner-border diff --git a/app/assets/stylesheets/extranet/_default/components/_facets.sass b/app/assets/stylesheets/extranet/_default/components/_facets.sass index 0dcefdb6663b5b768b3007bd484f3d24337cbe12..253f06bbe4989457afec183a6d78a32172b444ef 100644 --- a/app/assets/stylesheets/extranet/_default/components/_facets.sass +++ b/app/assets/stylesheets/extranet/_default/components/_facets.sass @@ -18,7 +18,7 @@ .faceted__facets > li - margin-bottom: px2rem(40) + margin-bottom: 40px b display: block margin-bottom: px2rem(10) diff --git a/app/assets/stylesheets/extranet/_default/components/_list.sass b/app/assets/stylesheets/extranet/_default/components/_list.sass index 819cdddea6347c29d342a820ce29cbd947febbf9..a34dd47af9c7599858cbc9839407d9c39e7b73b9 100644 --- a/app/assets/stylesheets/extranet/_default/components/_list.sass +++ b/app/assets/stylesheets/extranet/_default/components/_list.sass @@ -6,8 +6,8 @@ border-bottom: 1px solid $list-border-color display: flex justify-content: space-between - padding-bottom: px2rem(25) - padding-top: px2rem(25) + padding-bottom: 25px + padding-top: 25px position: relative &:first-of-type border-top: 1px solid $list-border-color diff --git a/app/assets/stylesheets/extranet/_default/layouts/_footer.sass b/app/assets/stylesheets/extranet/_default/layouts/_footer.sass index 44a3714b377f596bd2003c73b49172ff063464f4..7090ab37c9e8d0f9b26bdb00f6bb5a804d01c8ba 100644 --- a/app/assets/stylesheets/extranet/_default/layouts/_footer.sass +++ b/app/assets/stylesheets/extranet/_default/layouts/_footer.sass @@ -6,7 +6,7 @@ footer border-bottom: 1px solid $primary img height: auto - width: 100px + width: 75px nav a display: block diff --git a/app/assets/stylesheets/extranet/_default/layouts/_header.sass b/app/assets/stylesheets/extranet/_default/layouts/_header.sass index 882ba9ffec05ebf7d1e9389cbbf842c208cc916a..e3b744dc724b2a5db72ae14c429d0d3a665e1fa3 100644 --- a/app/assets/stylesheets/extranet/_default/layouts/_header.sass +++ b/app/assets/stylesheets/extranet/_default/layouts/_header.sass @@ -1,33 +1,16 @@ -.navbar - margin-bottom: 100px - .navbar-brand - img - max-width: 100px - .active - .nav-link - color: $navbar-link-active-color - .navbar-collapse - flex-grow: 0 - @include media-breakpoint-up(md) - .nav - li:last-child .nav-link - padding-right: 0 - @include media-breakpoint-down(md) - .nav - display: block - text-align: right - .navbar-toggler - border: 0 - font-size: px2rem(14) - line-height: px2rem(28) - text-transform: uppercase header align-items: center border-bottom: 1px solid border-top: 1px solid - display: flex - justify-content: space-between - min-height: 160px - h1, p - margin: 0 - padding: 0 + padding: 20px 0 + h1 + margin-bottom: px2rem(10) + p + margin-bottom: 0 + @include media-breakpoint-up(md) + display: flex + justify-content: space-between + min-height: 160px + h1, p + margin: 0 + padding: 0 diff --git a/app/assets/stylesheets/extranet/_default/layouts/_nav.sass b/app/assets/stylesheets/extranet/_default/layouts/_nav.sass new file mode 100644 index 0000000000000000000000000000000000000000..e7b67e6af3d7d1af2a5f43f1d089be761a7ca6c9 --- /dev/null +++ b/app/assets/stylesheets/extranet/_default/layouts/_nav.sass @@ -0,0 +1,24 @@ +.navbar + margin-bottom: 50px + .navbar-brand + img + max-width: 100px + .active + .nav-link + color: $navbar-link-active-color + .navbar-collapse + flex-grow: 0 + .navbar-toggler + border: 0 + font-size: px2rem(14) + line-height: px2rem(28) + text-transform: uppercase + @include media-breakpoint-up(md) + margin-bottom: 100px + .nav + li:last-child .nav-link + padding-right: 0 + @include media-breakpoint-down(md) + .nav + display: block + text-align: right diff --git a/app/assets/stylesheets/extranet/_default/pages/_default.sass b/app/assets/stylesheets/extranet/_default/pages/_default.sass index 8a8ca85754ea6988c796fd47af16879aab5e57d3..02b6572f60f68ae120086fd2097070fe027f2011 100644 --- a/app/assets/stylesheets/extranet/_default/pages/_default.sass +++ b/app/assets/stylesheets/extranet/_default/pages/_default.sass @@ -1,18 +1,19 @@ .action-show - .top - align-items: stretch - header - align-items: center - display: flex - height: 100% - h1 - margin: 0 - padding: 0 + @include media-breakpoint-up(md) + .top + align-items: stretch + header + align-items: center + display: flex + height: 100% + h1 + margin: 0 + padding: 0 + .biography + padding-right: percentage(3/9) dl line-height: px2rem(26) dt font-size: $small-font-size dd margin-bottom: px2rem(26) - .biography - padding-right: percentage(3/9) \ No newline at end of file diff --git a/app/assets/stylesheets/extranet/_default/pages/_home.sass b/app/assets/stylesheets/extranet/_default/pages/_home.sass index 67b14858c24ac4041ae832635669c23430d37c8c..bf1b57b4761d863b45133bfa837c1836596de8fd 100644 --- a/app/assets/stylesheets/extranet/_default/pages/_home.sass +++ b/app/assets/stylesheets/extranet/_default/pages/_home.sass @@ -3,21 +3,18 @@ ul padding-left: 0 li - @include make-row - display: flex + border-bottom: 1px solid $light-border-color + margin-bottom: 30px position: relative > div - @include make-col-ready &:nth-child(1) - width: percentage(2/9) + img + width: 100% &:nth-child(2) - @include pseudo-bottom-border - @include pseudo-top-border display: flex justify-content: space-between - padding-bottom: px2rem(16) - padding-top: px2rem(12) - width: percentage(7/9) + padding-bottom: 16px + padding-top: 12px display: flex justify-content: space-between > div @@ -29,7 +26,19 @@ align-self: center img max-height: 80px - + @include media-breakpoint-up(md) + li + @include make-row + border-bottom: 0 + margin-bottom: px2rem(16) + > div + @include make-col-ready + &:nth-child(1) + width: percentage(2/9) + &:nth-child(2) + @include pseudo-bottom-border + @include pseudo-top-border + width: percentage(7/9) .promotions list-style: none padding-left: 0 diff --git a/app/assets/stylesheets/extranet/_default/pages/_person.sass b/app/assets/stylesheets/extranet/_default/pages/_person.sass index af7455fd05eaa0d75ef9d797d85a222d05ee962b..bfb4f09178f5f65867d49e0976fd07ef1500a5e5 100644 --- a/app/assets/stylesheets/extranet/_default/pages/_person.sass +++ b/app/assets/stylesheets/extranet/_default/pages/_person.sass @@ -2,7 +2,9 @@ .top h1 font-weight: normal - + @include media-breakpoint-down(md) + header + border-bottom: 0 .experiences margin-top: px2rem(80) ul diff --git a/app/assets/stylesheets/extranet/themes/IJBA/_variables.sass b/app/assets/stylesheets/extranet/themes/IJBA/_variables.sass index e121f06fb4ee470314cb71f35044c3a3a6b692d7..16c6699a16fa63dad84577425c3e4df9d71d218f 100644 --- a/app/assets/stylesheets/extranet/themes/IJBA/_variables.sass +++ b/app/assets/stylesheets/extranet/themes/IJBA/_variables.sass @@ -1,5 +1,3 @@ $ijba-red: #E40130 $navbar-link-active-color: $ijba-red - -$breadcrumb-active-color: $ijba-red diff --git a/app/controllers/admin/communication/website/posts_controller.rb b/app/controllers/admin/communication/website/posts_controller.rb index f8c2cad42663fc419dcda29e3d5e2acd0982a646..ed0dc79e12d090a29aa9ff9f47497aefc9fff1b8 100644 --- a/app/controllers/admin/communication/website/posts_controller.rb +++ b/app/controllers/admin/communication/website/posts_controller.rb @@ -12,9 +12,9 @@ class Admin::Communication::Website::PostsController < Admin::Communication::Web def index @posts = apply_scopes(@posts).ordered.page params[:page] - @authors = apply_scopes(@website.authors.accessible_by(current_ability)) + @authors = @website.authors.accessible_by(current_ability) .ordered - .page(params[:page]) + .page(params[:authors_page]) @root_categories = @website.categories.root.ordered breadcrumb end diff --git a/app/controllers/server/universities_controller.rb b/app/controllers/server/universities_controller.rb index db42c8fb802eb74ae03e190555a1b4bc74d9c2df..7993a87cfea8b6c12957c0e7ad198585dba0c135 100644 --- a/app/controllers/server/universities_controller.rb +++ b/app/controllers/server/universities_controller.rb @@ -62,7 +62,7 @@ class Server::UniversitiesController < Server::ApplicationController params.require(:university).permit(:name, :address, :zipcode, :city, :country, :private, :identifier, :logo, :logo_delete, :sms_sender_name, - :has_sso, :sso_target_url, :sso_cert, :sso_name_identifier_format, + :has_sso, :sso_target_url, :sso_cert, :sso_name_identifier_format, :sso_mapping, :invoice_date, :invoice_amount) end end diff --git a/app/models/user/with_omniauth.rb b/app/models/user/with_omniauth.rb index f0de9a849352717c3274fbb4eaea58713ed5bfeb..24d8361368f2e7c1cdceb2bdd808f89df7b45fa1 100644 --- a/app/models/user/with_omniauth.rb +++ b/app/models/user/with_omniauth.rb @@ -5,10 +5,9 @@ module User::WithOmniauth def self.from_omniauth(university, attributes) mapping = university.sso_mapping - email = 'pierreandre.boissinot@noesya.coop' - # email_sso_key = mapping.select { |elmt| elmt['internal_key'] == 'email' }&.first&.dig('sso_key') - email_sso_key = 'email' + # first step: we find the email (we are supposed to have an email mapping) + email_sso_key = mapping.select { |elmt| elmt['internal_key'] == 'email' }&.first&.dig('sso_key') email = attributes.dig(email_sso_key) return unless email email = email.first if email.is_a?(Array) @@ -17,6 +16,47 @@ module User::WithOmniauth user = User.where(university: university, email: email).first_or_create do |u| u.password = "#{Devise.friendly_token[0,20]}!" # meets password complexity requirements end + + # update user data according to mapping & infos provided by SSO + mapping.select { |elmt| elmt['internal_key'] != 'email' }.each do |mapping_element| + user = self.update_data_for_mapping_element(user, mapping_element, attributes) + end + + user.save + user + end + + protected + + def self.update_data_for_mapping_element(user, mapping_element, attributes) + sso_key = mapping_element['sso_key'] + return user if attributes[sso_key].nil? # if not provided by sso, just return + internal_key = mapping_element['internal_key'] + user = self.update_data_for_mapping_element_standard(user, mapping_element, self.get_provided_answer(attributes[sso_key])) + user + end + + def self.update_data_for_mapping_element_standard(user, mapping_element, sso_value) + case mapping_element['internal_key'] + when 'language' + user = self.set_best_id_for(user, mapping_element['type'], sso_value.first) + when 'role' + value = mapping_element['roles'].select { |key, val| val == sso_value.first }.first&.first + user['role'] = value if value + else + user[mapping_element['internal_key']] = sso_value.first + end + user + end + + def self.get_provided_answer(value) + # SAML send an array (even for a single value) where OAuth2 send a string for single values. We harmonize to always get an array + value.is_a?(Array) ? value : [value] + end + + def self.set_best_id_for(user, type, iso) + element_id = eval(type.classify).find_by(iso_code: iso)&.id + user["#{type}_id"] = element_id unless element_id.nil? user end diff --git a/app/views/admin/communication/blocks/edit.html.erb b/app/views/admin/communication/blocks/edit.html.erb index ea57a1e441a8671e0d736fecd7e9064d68ff2385..e126220a12c759bcd08d3d5ee3c88aa013bcc9f3 100644 --- a/app/views/admin/communication/blocks/edit.html.erb +++ b/app/views/admin/communication/blocks/edit.html.erb @@ -6,7 +6,7 @@ %> <div id="app" v-cloak> <div class="spinner-border text-primary" role="status"> - <span class="sr-only">Loading...</span> + <span class="sr-only"><%= t 'loading' %></span> </div> <%= simple_form_for [:admin, @block] do |f| %> <div class="row"> diff --git a/app/views/admin/communication/website/posts/index.html.erb b/app/views/admin/communication/website/posts/index.html.erb index 5688ab1246448d9cb21c061ef28b70d5c14869bb..bdfc24a223644721d5a01920b125e29d7c7fa7f1 100644 --- a/app/views/admin/communication/website/posts/index.html.erb +++ b/app/views/admin/communication/website/posts/index.html.erb @@ -57,6 +57,11 @@ <h2 class="card-title"><%= t('communication.authors', count: 2) %></h2> </div> <%= render 'admin/communication/website/authors/list', authors: @authors %> + <% if @authors.total_pages > 1 %> + <div class="card-footer"> + <%= paginate @authors, theme: 'bootstrap-5', param_name: :authors_page %> + </div> + <% end %> </div> </div> </div> diff --git a/app/views/extranet/academic_years/index.html.erb b/app/views/extranet/academic_years/index.html.erb index cea67d2fda51a285d1b0cd7f0335f9fa2f11f41e..75a8507bde2d44ac622b5407b18b02783966cbdc 100644 --- a/app/views/extranet/academic_years/index.html.erb +++ b/app/views/extranet/academic_years/index.html.erb @@ -20,11 +20,11 @@ <%= link_to year, year, class: 'stretched-link' %> </b> </div> - <div class="col-md-3"> + <div class="col-md-3 col-6"> <%= alumni.count %> <%= University::Person::Alumnus.model_name.human(count: alumni.count).downcase %> </div> - <div class="col-md-3 text-end"> + <div class="col-md-3 col-6 text-end"> <%= cohorts.count %> <%= Education::Cohort.model_name.human(count: cohorts.count).downcase %> </div> diff --git a/app/views/extranet/home/index.html.erb b/app/views/extranet/home/index.html.erb index 79061617f3252ea6020feb97110baf923a4a45cb..9bc0e0feaf3123faacb69abd3c9cf525a96c1077 100644 --- a/app/views/extranet/home/index.html.erb +++ b/app/views/extranet/home/index.html.erb @@ -6,13 +6,16 @@ <div class="experiences"> <ul> <% @experiences.ordered.each do |experience| %> - <li class="mb-3"> + <li> <div> <%= link_to experience.person, class: "stretched-link" do %> <% if experience.person.picture.attached? %> - <%= kamifusen_tag experience.person.picture, width: 200, class: 'img-fluid' %> + <%= kamifusen_tag experience.person.picture, width: 400, class: 'img-fluid', sizes: { + '(max-width: 576px)': '400px', + '(max-width: 991px)': '200px' + } %> <% else %> - <%= image_tag 'extranet/avatar.png', width: 200, class: 'img-fluid' %> + <%= image_tag 'extranet/avatar.png', width: 400, class: 'img-fluid' %> <% end %> <% end %> </div> diff --git a/app/views/extranet/persons/_person.html.erb b/app/views/extranet/persons/_person.html.erb index eabc0824e4dd18407a2e362dc45ee009f280415d..1374df4e4aaea2a9b6dc0c69ba503dad9e26409c 100644 --- a/app/views/extranet/persons/_person.html.erb +++ b/app/views/extranet/persons/_person.html.erb @@ -1,6 +1,10 @@ <article class="mb-4 person"> <% if person.picture.attached? %> - <%= kamifusen_tag person.picture, width: 400, class: 'img-fluid mb-2' %> + <%= kamifusen_tag person.picture, width: 400, class: 'img-fluid mb-2', + sizes: { + '(max-width: 576px)': '400px', + '(max-width: 991px)': '200px' + } %> <% else %> <%= image_tag 'extranet/avatar.png', width: 400, class: 'img-fluid mb-2' %> <% end %> diff --git a/app/views/extranet/persons/index.html.erb b/app/views/extranet/persons/index.html.erb index 0fa3459638104672652500765ceb39aa5ced0e9a..590f4e7cfe4ef87eb07e0e2693794d294b4c8e5f 100644 --- a/app/views/extranet/persons/index.html.erb +++ b/app/views/extranet/persons/index.html.erb @@ -8,7 +8,7 @@ </header> <div class="row"> - <div class="col-md-3"> + <div class="col-lg-3"> <%= render 'faceted_search/facets', facets: @facets %> </div> <div class="offset-lg-1 col-lg-8"> diff --git a/app/views/server/universities/_form.html.erb b/app/views/server/universities/_form.html.erb index c3e2a4743b9860e325cb2746712564c7c1375b42..5ef1508748f0d071e6782559b6453803da92fbbb 100644 --- a/app/views/server/universities/_form.html.erb +++ b/app/views/server/universities/_form.html.erb @@ -28,16 +28,19 @@ </div> </div> + <h3 class="mt-5"><%= t('university.sso') %></h3> <div class="row"> <div class="col-md-6"> - <h3 class="mt-5"><%= t('university.sso') %></h3> <%= f.input :has_sso %> <div id="sso-inputs"> <%= f.input :sso_target_url, required: true %> <%= f.input :sso_cert, required: true %> <%= f.input :sso_name_identifier_format, required: true %> - - <%#= render 'sso_mapping', brand: brand %> + </div> + </div> + <div class="col-md-6"> + <h4 class="mb-4"><%= University.human_attribute_name('sso_mapping') %></h4> + <%= render 'sso_mapping', university: university %> </div> </div> diff --git a/app/views/server/universities/_sso_mapping.html.erb b/app/views/server/universities/_sso_mapping.html.erb new file mode 100644 index 0000000000000000000000000000000000000000..a1fc7362a035003a0e4e5cc843a494d284017d58 --- /dev/null +++ b/app/views/server/universities/_sso_mapping.html.erb @@ -0,0 +1,82 @@ +<% mapping_keys = ['email', 'first_name', 'last_name', 'role', 'mobile_phone', 'language', 'picture_url'] %> + +<%# Include vue.js before call Vue.createApp %> +<%= javascript_include_tag 'vue' %> + +<div id="app" v-cloak> + <div class="spinner-border text-primary" role="status"> + <span class="sr-only"><%= t('loading') %></span> + </div> + + <div class="app-form"> + <draggable :list="fields"> + <div v-for="(field, index) in fields"> + <div class="card"> + <div class="card-header d-flex justify-content-between"> + <a data-bs-toggle="collapse" :href="'#sso_mapping_collapse_' + index "> + {{index + 1}}. {{ field.sso_key }} -> {{ keys[field.internal_key]}} + </a> + <a + v-on:click="fields.splice(fields.indexOf(field), 1)" + title="Remove field"> + <i class="far fa-trash-alt"></i> + </a> + </div> + <div class="card-body collapse pt-0" :id="'sso_mapping_collapse_' + index "> + <hr class="mt-0"> + <div class="form-group"> + <label for="" class="form-control-label"><%= t('university.sso_key') %> <abbr title="required">*</abbr></label> + <input + v-model="field.sso_key" + type="text" class="form-control"> + </div> + <div class="form-group"> + <label for="" class="form-control-label"><%= t('university.internal_key') %> <abbr title="required">*</abbr></label> + <select v-model="field.internal_key" id="" class="form-select" required> + <option v-for="(label, key) in keys" :value="key">{{ label }}</option> + </select> + </div> + <div v-if="field.internal_key === 'role'"> + <hr class="mt-4"> + <% User.roles.keys.each do |role| %> + <div class="form-group"> + <label for="" class="form-label"><%= t("activerecord.attributes.user.roles.#{role}") %></label> + <input v-model="field.roles.<%= role %>" type="text" class="form-control"> + </div> + <% end %> + </div> + </div> + </div> + </div> + </draggable> + + <a v-on:click="fields.push({sso_key: 'key', internal_key: 'email', roles: {}})" class="btn btn-primary btn-sm"> + <%= t('add_field') %> + </a> + </div> + + <textarea name="university[sso_mapping]" id="university_sso_mapping" rows="20" cols="200" class="d-none"> + {{ JSON.stringify(fields) }} + </textarea> + +</div> + +<script> + var app = Vue.createApp({ + components: { + draggable: VueDraggableNext.VueDraggableNext, + }, + data() { + return { + fields: <%= university.sso_mapping.blank? ? '[]' : university.sso_mapping.to_json.html_safe %>, + keys: <%= mapping_keys.map { |key| [key, User.human_attribute_name(key)] }.to_h.to_json.html_safe %> + } + } + }); + + window.addEventListener('load', function(){ + setTimeout(function() { + app.mount('#app'); + }, 1000); + }); +</script> diff --git a/config/application.rb b/config/application.rb index 42ca666b01aa256c57a18f21684d2147d5fb0743..959a1b37c9dd48f10200d5d67e983c9b720b337b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -67,7 +67,7 @@ module Osuny "sgid", "content-type", "url", "filename", "filesize", "previewable" ] - config.allowed_special_chars = '#?!,@$%^&*+:;£µ-' + config.allowed_special_chars = '#?!,_@$%^&*+:;£µ-' config.generators do |g| g.orm :active_record, primary_key_type: :uuid diff --git a/config/locales/en.yml b/config/locales/en.yml index aeaae6cf586c5b579138ff01856c8448b6fc191c..49071105af0eb9cde4fe42461b5a176d54940a54 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -38,6 +38,7 @@ en: one: User other: Users add: Add + add_field: Add field admin: attachment_not_available: Attachment not available dashboard: Dashboard diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 98572ab5182adbebcfee951ea6724522753a471a..ab257fa73827bc04141c59c0edb70995dd5fcf76 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -38,6 +38,7 @@ fr: one: Utilisateur·rice other: Utilisateur·rice·s add: Ajouter + add_field: Ajouter un champ admin: attachment_not_available: Impossible d'accéder à l'élément dashboard: Tableau de bord diff --git a/config/locales/university/en.yml b/config/locales/university/en.yml index 63a83a4e13d8231a460c218b51e5f82d13f3b9b9..8b3633d360145d4a543319a7e49e7a7415508b2a 100644 --- a/config/locales/university/en.yml +++ b/config/locales/university/en.yml @@ -16,6 +16,7 @@ en: public_or_private: Public/private sms_sender_name: SMS sender name sso_cert: Certificate + sso_mapping: Mapping sso_name_identifier_format: Name Identifier Format sso_target_url: Target URL url: URL @@ -125,8 +126,10 @@ en: non_profit: Association government: Government university: + internal_key: Internal Key invoice_informations: Invoice informations person: administrator_roles: Administrator roles taught_programs: Taught programs sso: SSO + sso_key: SSO Key diff --git a/config/locales/university/fr.yml b/config/locales/university/fr.yml index f263aead1db38c3fbd9063474e72394808a2a9e9..049859fb4d3a08af294c74cf74428c0d05e2e548 100644 --- a/config/locales/university/fr.yml +++ b/config/locales/university/fr.yml @@ -16,6 +16,7 @@ fr: public_or_private: Public/privé sms_sender_name: Nom de l'expéditeur SMS sso_cert: Certificat + sso_mapping: Mapping sso_name_identifier_format: Name Identifier Format sso_target_url: URL cible url: 'URL' @@ -125,8 +126,10 @@ fr: non_profit: Association government: Structure gouvernementale university: + internal_key: Clé interne invoice_informations: Données de facturation person: administrator_roles: Rôles administratifs taught_programs: Formations enseignées sso: SSO + sso_key: Clé sur le SSO