Bugzilla – Attachment 193627 Details for
Bug 39224
Migrate SAML/Shibboleth configuration into Identity Providers
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 39224: Identity providers Vue SPA and admin interface
855ed6d.patch (text/plain), 143.49 KB, created by
Martin Renvoize (ashimema)
on 2026-02-23 11:17:09 UTC
(
hide
)
Description:
Bug 39224: Identity providers Vue SPA and admin interface
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2026-02-23 11:17:09 UTC
Size:
143.49 KB
patch
obsolete
>From 855ed6d292bef5a2b2ce3eebd01f167a06610230 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@openfifth.co.uk> >Date: Sun, 22 Feb 2026 20:19:52 +0000 >Subject: [PATCH] Bug 39224: Identity providers Vue SPA and admin interface > >Replaces the legacy jQuery-based identity providers admin page with >a modern Vue 3 SPA: > >- Vue router with views for list, show, add, and edit providers >- Provider, domain, and mapping resource components >- API client for identity provider endpoints >- Rspack build configuration for the new module >- Updated OPAC templates for hostname-based provider selection >- Updated system preferences (moves provider prefs to admin section) >- Apache configuration for Vue SPA routing >- Simplified admin/identity_providers.pl (delegates to Vue SPA) > >Test plan: >1. Navigate to Administration > Identity providers >2. Verify the list view shows existing providers >3. Add a new identity provider with mappings >4. Edit an existing provider >5. Verify OPAC login shows appropriate provider buttons >6. Run: yarn build && restart_all > >Sponsored-by: ByWater Solutions >--- > C4/Auth.pm | 6 +- > admin/identity_providers.pl | 303 +------ > debian/templates/apache-shared-intranet.conf | 1 + > .../prog/en/includes/admin-menu.inc | 1 - > .../en/modules/admin/identity_providers.tt | 601 +------------ > .../en/modules/admin/preferences/opac.pref | 6 - > .../admin/preferences/staff_interface.pref | 6 - > .../intranet-tmpl/prog/js/fetch/api-client.js | 3 + > .../js/fetch/identity-providers-api-client.js | 171 ++++ > .../IdentityProviders/DomainResource.vue | 261 ++++++ > .../vue/components/IdentityProviders/Main.vue | 76 ++ > .../IdentityProviders/MappingResource.vue | 221 +++++ > .../IdentityProviders/ProviderResource.vue | 840 ++++++++++++++++++ > .../js/vue/components/Islands/AdminMenu.vue | 13 - > .../prog/js/vue/fetch/api-client.js | 2 + > .../prog/js/vue/modules/identity-providers.ts | 76 ++ > .../prog/js/vue/routes/identity-providers.js | 125 +++ > .../prog/js/vue/stores/identity-providers.js | 15 + > .../prog/js/vue/stores/navigation.js | 3 + > .../bootstrap/en/includes/masthead.inc | 2 +- > .../bootstrap/en/modules/opac-auth.tt | 168 ++-- > opac/opac-user.pl | 7 +- > rspack.config.js | 2 + > 23 files changed, 1934 insertions(+), 975 deletions(-) > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/fetch/identity-providers-api-client.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/DomainResource.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/Main.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/MappingResource.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/ProviderResource.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/modules/identity-providers.ts > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/routes/identity-providers.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/identity-providers.js > >diff --git a/C4/Auth.pm b/C4/Auth.pm >index bae5ebe4150..78152373569 100644 >--- a/C4/Auth.pm >+++ b/C4/Auth.pm >@@ -173,7 +173,7 @@ sub get_template_and_user { > my $cookie_mgr = Koha::CookieManager->new; > > # Get shibboleth login attribute >- my $shib = C4::Context->preference('ShibbolethAuthentication') && shib_ok(); >+ my $shib = shib_ok(); > my $shib_login = $shib ? get_login_shib() : undef; > > C4::Context->interface( $in->{type} ); >@@ -811,7 +811,7 @@ sub checkauth { > my $query = shift; > > # Get shibboleth login attribute >- my $shib = C4::Context->preference('ShibbolethAuthentication') && shib_ok(); >+ my $shib = shib_ok(); > my $shib_login = $shib ? get_login_shib() : undef; > > # $authnotrequired will be set for scripts which will run without authentication >@@ -2009,7 +2009,7 @@ sub checkpw { > $type = 'opac' unless $type; > > # Get shibboleth login attribute >- my $shib = C4::Context->preference('ShibbolethAuthentication') && shib_ok(); >+ my $shib = shib_ok(); > my $shib_login = $shib ? get_login_shib() : undef; > > my $anonymous_patron = C4::Context->preference('AnonymousPatron'); >diff --git a/admin/identity_providers.pl b/admin/identity_providers.pl >index 7f2d6a775d3..3e8aadf45af 100755 >--- a/admin/identity_providers.pl >+++ b/admin/identity_providers.pl >@@ -19,295 +19,56 @@ > > use Modern::Perl; > >-use CGI qw ( -utf8 ); >-use Scalar::Util qw( blessed ); >-use Try::Tiny qw( catch try ); >+use CGI qw ( -utf8 ); > >-use C4::Auth qw( get_template_and_user ); >-use C4::Output qw( output_html_with_http_headers ); >+use C4::Auth qw( get_template_and_user ); >+use C4::Context qw(); >+use C4::Output qw( output_html_with_http_headers ); > >-use Koha::Database; >-use Koha::Auth::Identity::Providers; >+use Koha::Libraries; >+use Koha::Patron::Categories; >+use Koha::Patrons; > >-my $input = CGI->new; >-my $op = $input->param('op') || 'list'; >-my $domain_ops = $input->param('domain_ops'); >-my $identity_provider_id = $input->param('identity_provider_id'); >-my $identity_provider; >- >-$identity_provider = Koha::Auth::Identity::Providers->find($identity_provider_id) >- unless !$identity_provider_id; >- >-my $template_name = $domain_ops ? 'admin/identity_provider_domains.tt' : 'admin/identity_providers.tt'; >+my $input = CGI->new; > > my ( $template, $borrowernumber, $cookie ) = get_template_and_user( > { >- template_name => $template_name, >+ template_name => 'admin/identity_providers.tt', > query => $input, > type => "intranet", > flagsrequired => { parameters => 'manage_identity_providers' }, > } > ); > >-my @messages; >- >-if ( !$domain_ops && $op eq 'cud-add' ) { >- >- # IdP configuration params >- my $code = $input->param('code'); >- my $config = $input->param('config'); >- my $description = $input->param('description'); >- my $icon_url = $input->param('icon_url'); >- my $mapping = $input->param('mapping'); >- my $matchpoint = $input->param('matchpoint'); >- my $protocol = $input->param('protocol'); >- >- # Domain configuration params >- my $allow_opac = $input->param('allow_opac') // 0; >- my $allow_staff = $input->param('allow_staff') // 0; >- my $auto_register_opac = $input->param('auto_register_opac') // 0; >- my $auto_register_staff = $input->param('auto_register_staff') // 0; >- my $default_category_id = $input->param('default_category_id'); >- my $default_library_id = $input->param('default_library_id'); >- my $domain = $input->param('domain'); >- my $update_on_auth = $input->param('update_on_auth'); >- >- try { >- Koha::Database->new->schema->txn_do( >- sub { >- my $provider = Koha::Auth::Identity::Provider->new( >- { >- code => $code, >- config => $config, >- description => $description, >- icon_url => $icon_url, >- mapping => $mapping, >- matchpoint => $matchpoint, >- protocol => $protocol, >- } >- )->store; >- >- Koha::Auth::Identity::Provider::Domain->new( >- { >- identity_provider_id => $provider->identity_provider_id, >- allow_opac => $allow_opac, >- allow_staff => $allow_staff, >- auto_register_opac => $auto_register_opac, >- auto_register_staff => $auto_register_staff, >- default_category_id => $default_category_id, >- default_library_id => $default_library_id, >- domain => $domain, >- update_on_auth => $update_on_auth, >- } >- )->store; >- >- push @messages, { type => 'message', code => 'success_on_insert' }; >- } >- ); >- } catch { >- if ( blessed $_ and $_->isa('Koha::Exceptions::Object::DuplicateID') ) { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_insert', >- reason => 'duplicate_id' >- }; >- } >- }; >- >- # list servers after adding >- $op = 'list'; >-} elsif ( $domain_ops && $op eq 'cud-add' ) { >- >- my $allow_opac = $input->param('allow_opac'); >- my $allow_staff = $input->param('allow_staff'); >- my $identity_provider_id = $input->param('identity_provider_id'); >- my $auto_register_opac = $input->param('auto_register_opac'); >- my $auto_register_staff = $input->param('auto_register_staff'); >- my $default_category_id = $input->param('default_category_id') || undef; >- my $default_library_id = $input->param('default_library_id') || undef; >- my $domain = $input->param('domain'); >- my $update_on_auth = $input->param('update_on_auth'); >- >- try { >- >- Koha::Auth::Identity::Provider::Domain->new( >- { >- allow_opac => $allow_opac, >- allow_staff => $allow_staff, >- identity_provider_id => $identity_provider_id, >- auto_register_opac => $auto_register_opac, >- auto_register_staff => $auto_register_staff, >- default_category_id => $default_category_id, >- default_library_id => $default_library_id, >- domain => $domain, >- update_on_auth => $update_on_auth, >- } >- )->store; >- >- push @messages, { type => 'message', code => 'success_on_insert' }; >- } catch { >- if ( blessed $_ and $_->isa('Koha::Exceptions::Object::DuplicateID') ) { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_insert', >- reason => 'duplicate_id' >- }; >- } >- }; >- >- # list servers after adding >- $op = 'list'; >-} elsif ( !$domain_ops && $op eq 'edit_form' ) { >- >- if ($identity_provider) { >- $template->param( identity_provider => $identity_provider ); >- } else { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_edit', >- reason => 'invalid_id' >- }; >- } >-} elsif ( $domain_ops && $op eq 'edit_form' ) { >- my $identity_provider_domain_id = $input->param('identity_provider_domain_id'); >- my $identity_provider_domain; >- >- $identity_provider_domain = Koha::Auth::Identity::Provider::Domains->find($identity_provider_domain_id) >- unless !$identity_provider_domain_id; >- >- if ($identity_provider_domain) { >- $template->param( identity_provider_domain => $identity_provider_domain ); >- } else { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_edit', >- reason => 'invalid_id' >- }; >- } >-} elsif ( !$domain_ops && $op eq 'cud-edit_save' ) { >- >- if ($identity_provider) { >- >- my $code = $input->param('code'); >- my $config = $input->param('config'); >- my $description = $input->param('description'); >- my $icon_url = $input->param('icon_url'); >- my $mapping = $input->param('mapping'); >- my $matchpoint = $input->param('matchpoint'); >- my $protocol = $input->param('protocol'); >- >- try { >- >- $identity_provider->set( >- { >- code => $code, >- config => $config, >- description => $description, >- icon_url => $icon_url, >- mapping => $mapping, >- matchpoint => $matchpoint, >- protocol => $protocol, >- } >- )->store; >- >- push @messages, >- { >- type => 'message', >- code => 'success_on_update' >- }; >- } catch { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_update' >- }; >- }; >- >- # list servers after adding >- $op = 'list'; >- } else { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_update', >- reason => 'invalid_id' >- }; >- } >-} elsif ( $domain_ops && $op eq 'cud-edit_save' ) { >- >- my $identity_provider_domain_id = $input->param('identity_provider_domain_id'); >- my $identity_provider_domain; >- >- $identity_provider_domain = Koha::Auth::Identity::Provider::Domains->find($identity_provider_domain_id) >- unless !$identity_provider_domain_id; >- >- if ($identity_provider_domain) { >- >- my $identity_provider_id = $input->param('identity_provider_id'); >- my $domain = $input->param('domain'); >- my $auto_register_opac = $input->param('auto_register_opac') // 0; >- my $auto_register_staff = $input->param('auto_register_staff') // 0; >- my $update_on_auth = $input->param('update_on_auth'); >- my $default_library_id = $input->param('default_library_id') || undef; >- my $default_category_id = $input->param('default_category_id') || undef; >- my $allow_opac = $input->param('allow_opac'); >- my $allow_staff = $input->param('allow_staff'); >- >- try { >- >- $identity_provider_domain->set( >- { >- identity_provider_id => $identity_provider_id, >- domain => $domain, >- auto_register_opac => $auto_register_opac, >- auto_register_staff => $auto_register_staff, >- update_on_auth => $update_on_auth, >- default_library_id => $default_library_id, >- default_category_id => $default_category_id, >- allow_opac => $allow_opac, >- allow_staff => $allow_staff, >- } >- )->store; >+my $borrowers_source = Koha::Patrons->_resultset->result_source; >+my @borrower_columns; >+my %skip_columns = map { $_ => 1 } qw( password updated_on timestamp ); >+foreach my $column ( sort $borrowers_source->columns ) { >+ next if $skip_columns{$column}; >+ my $column_info = $borrowers_source->column_info($column); >+ my $label = $column_info->{comments} || $column; >+ push @borrower_columns, { value => $column, label => $label }; >+} > >- push @messages, >- { >- type => 'message', >- code => 'success_on_update' >- }; >- } catch { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_update' >- }; >- }; >+my @libraries_map = map { { value => $_->branchcode, label => $_->branchname } } >+ Koha::Libraries->search( {}, { order_by => 'branchname' } )->as_list; > >- # list servers after adding >- $op = 'list'; >- } else { >- push @messages, >- { >- type => 'alert', >- code => 'error_on_update', >- reason => 'invalid_id' >- }; >- } >-} >+my @categories_map = map { { value => $_->categorycode, label => $_->description } } >+ Koha::Patron::Categories->search( {}, { order_by => 'description' } )->as_list; > >-if ($domain_ops) { >- $template->param( >- identity_provider_code => $identity_provider->code, >- identity_provider_id => $identity_provider_id, >- ); >+my @idp_default_hostnames; >+for my $pref (qw( OPACBaseURL staffClientBaseURL )) { >+ my $url = C4::Context->preference($pref); >+ next unless $url; >+ my ($hostname) = $url =~ m|^https?://([^/:?#]+)|; >+ push @idp_default_hostnames, $hostname if $hostname; > } > > $template->param( >- op => $op, >- messages => \@messages, >+ borrower_columns => \@borrower_columns, >+ libraries_map => \@libraries_map, >+ categories_map => \@categories_map, >+ idp_default_hostnames => \@idp_default_hostnames, > ); > > output_html_with_http_headers $input, $cookie, $template->output; >diff --git a/debian/templates/apache-shared-intranet.conf b/debian/templates/apache-shared-intranet.conf >index 54155faef88..b47f279dde3 100644 >--- a/debian/templates/apache-shared-intranet.conf >+++ b/debian/templates/apache-shared-intranet.conf >@@ -24,6 +24,7 @@ RewriteRule ^(.*)_[0-9]{2}\.[0-9]{7}\.(js|css)$ $1.$2 [L] > > RewriteRule ^/cgi-bin/koha/erm/.*$ /cgi-bin/koha/erm/erm.pl [PT] > RewriteRule ^/cgi-bin/koha/sip2/.*$ /cgi-bin/koha/sip2/sip2.pl [PT] >+RewriteRule ^/cgi-bin/koha/shibboleth/.*$ /cgi-bin/koha/shibboleth/shibboleth.pl [PT] > RewriteCond %{REQUEST_URI} !^/cgi-bin/koha/preservation/.*.pl$ > RewriteRule ^/cgi-bin/koha/preservation/.*$ /cgi-bin/koha/preservation/home.pl [PT] > RewriteRule ^/cgi-bin/koha/admin/record_sources(.*)?$ /cgi-bin/koha/admin/record_sources.pl$1 [PT] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc >index 4488545dcd5..b93e631c573 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/admin-menu.inc >@@ -45,7 +45,6 @@ > can_user_parameters_manage_additional_fields="[% CAN_user_parameters_manage_additional_fields | html %]" > can_user_parameters_manage_keyboard_shortcuts="[% CAN_user_parameters_manage_keyboard_shortcuts | html %]" > can_user_ill="[% CAN_user_ill | html %]" >- shibbolethauthentication="[% Koha.Preference('ShibbolethAuthentication') | html %]" > usecirculationdesks="[% Koha.Preference('UseCirculationDesks') | html %]" > usecashregisters="[% Koha.Preference('UseCashRegisters') | html %]" > savedsearchfilters="[% Koha.Preference('SavedSearchFilters') | html %]" >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/identity_providers.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/identity_providers.tt >index 0b488f5520c..0d2a638fa61 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/identity_providers.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/identity_providers.tt >@@ -1,609 +1,42 @@ > [% USE raw %] >-[% USE Koha %] > [% USE Asset %] >-[% USE Branches %] >-[% USE Categories %] >+[% USE To %] > [% PROCESS 'i18n.inc' %] > [% SET footerjs = 1 %] > [% INCLUDE 'doc-head-open.inc' %] > <title > >[% FILTER collapse %] >- [% IF op == 'add_form' %] >- [% t("New identity provider") | html %] >- › >- [% ELSIF op == 'edit_form' %] >- [% tx("Modify identity provider '{id_provider}'", {id_provider = identity_provider.code}) | html %] >- › >- [% END %] > [% t("Identity providers") | html %] > › [% t("Administration") | html %] › [% t("Koha") | html %] >- [% END %]</title >-> >+ [% END %] >+</title> > [% INCLUDE 'doc-head-close.inc' %] > </head> > > <body id="admin_identity_providers" class="admin"> >-[% INCLUDE 'header.inc' %] >-[% INCLUDE 'prefs-admin-search.inc' %] >+[% WRAPPER 'header.inc' %] >+ [% INCLUDE 'prefs-admin-search.inc' %] >+[% END %] > > [% WRAPPER 'sub-header.inc' %] >- [% WRAPPER breadcrumbs %] >- [% WRAPPER breadcrumb_item %] >- <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> >- [% END %] >- >- [% IF op == 'add_form' %] >- [% WRAPPER breadcrumb_item %] >- <a href="/cgi-bin/koha/admin/identity_providers.pl">Identity providers</a> >- [% END %] >- [% WRAPPER breadcrumb_item bc_active= 1 %] >- <span>New identity provider</span> >- [% END %] >- [% ELSIF op == 'edit_form' %] >- [% WRAPPER breadcrumb_item %] >- <a href="/cgi-bin/koha/admin/identity_providers.pl">Identity providers</a> >- [% END %] >- [% WRAPPER breadcrumb_item bc_active= 1 %] >- [% tx("Modify identity provider '{id_provider}'", {id_provider = identity_provider.code}) | html %] >- [% END %] >- [% ELSE %] >- [% WRAPPER breadcrumb_item bc_active= 1 %] >- <span>Identity providers</span> >- [% END %] >- [% END %] >- [% END #/ WRAPPER breadcrumbs %] >+ <div id="vue-breadcrumbs-container"></div> > [% END #/ WRAPPER sub-header.inc %] > > [% WRAPPER 'main-container.inc' aside='admin-menu' %] >- >- [% FOREACH m IN messages %] >- <div class="alert alert-[% m.type | html %]" id="identity_provider_action_result_dialog"> >- [% SWITCH m.code %] >- [% CASE 'error_on_update' %] >- <span>An error occurred trying to open the identity provider for editing. The passed ID is invalid.</span> >- [% CASE 'error_on_insert' %] >- <span>An error occurred when adding a new identity provider. Check the logs for details.</span> >- [% CASE 'success_on_update' %] >- <span>Identity provider updated successfully.</span> >- [% CASE 'success_on_insert' %] >- <div>Identity provider added successfully.</div> >- <div>You will need to restart Koha for the provider to work.</div> >- [% CASE %] >- <span>[% m.code | html %]</span> >- [% END %] >- </div> >- [% END %] >- >- <div class="alert alert-info" id="identity_provider_delete_success" style="display: none;"></div> >- <div class="alert alert-warning" id="identity_provider_delete_error" style="display: none;"></div> >- >- [% IF op == 'add_form' %] >- <h1>New identity provider</h1> >- >- <form action="/cgi-bin/koha/admin/identity_providers.pl" id="add" name="add" class="validated" method="post"> >- [% INCLUDE 'csrf-token.inc' %] >- <input type="hidden" name="op" value="cud-add" /> >- <fieldset class="rows"> >- <legend id="identity_provider_basic">Basic configuration</legend> >- <ol> >- <li> >- <label for="code" class="required">Code: </label> >- <input type="text" name="code" id="code" size="60" class="required" required="required" /> >- <span class="required">Required</span> >- <div class="hint">Code that identifies this provider. Only alphanumeric and "_" characters are allowed</div> >- </li> >- <li> >- <label for="description" class="required">Description: </label> >- <input type="text" name="description" id="description" size="60" class="required" required="required" /> >- <span class="required">Required</span> >- <div class="hint">User friendly name of this provider</div> >- </li> >- <li> >- <label for="protocol">Protocol: </label> >- <select name="protocol" id="protocol"> >- <option value="OAuth">OAuth</option> >- <option value="OIDC">OIDC</option> >- <!-- Not implemented yet >- <option value="LDAP">LDAP</option> >- <option value="CAS">CAS</option> >- --> >- </select> >- <div class="hint">Choose the protocol this external identity provider uses</div> >- </li> >- </ol> >- </fieldset> >- >- <fieldset class="rows"> >- <legend id="identity_provider_advanced">Advanced configuration</legend> >- <ol> >- <li> >- <label for="config" class="required json">Configuration: </label> >- <textarea name="config" id="config" class="required" cols="75" rows="10"></textarea> >- <span class="required">Required</span> >- <div class="hint" >- >Provider's main configuration. <button class="more btn btn-light" data-target="config"><i class="fa fa-caret-down"></i> [% tp("Show more information", "More") | html %]</button></div >- > >- <div class="hint more-config" style="display: none"> >- <div>This configuration differs for each protocol.</div> >- <div>It is recommended to add the default configuration, and then replace with appropriate values</div> >- </div> >- <div class="hint"> >- <button class="btn btn-default defaults" data-default-target="config" id="default-config">Add default OAuth configuration</button> >- </div> >- </li> >- <li> >- <label for="mapping" class="required json">Mapping: </label> >- <textarea name="mapping" id="mapping" class="required" cols="75" rows="10"></textarea> >- <span class="required">Required</span> >- <div class="hint" >- >Map provider's result to Koha patron's fields. <button class="more btn btn-light" data-target="mapping"><i class="fa fa-caret-down"></i> [% tp("Show more information", "More") | html %]</button></div >- > >- <div class="hint more-mapping" style="display: none"> >- <div>It is recommended to add the default mapping, and then modify to suit this provider's response</div> >- <div>Keys represent Koha's fields, and values represent the keys in provider's result</div> >- <div>For nested values in provider's results, you can use dot separation.</div> >- <div>For example, <i>firstname: "users.0.name"</i> will match the 'name' attribute of the first object in the array named 'users', and place it in 'firstname' field</div> >- <div>If you plan to use auto register feature, either <i>userid</i> or <i>cardnumber</i> must be present in this mapping</div> >- </div> >- <div class="hint"> >- <button class="btn btn-default defaults" data-default-target="mapping" id="default-mapping">Add default OAuth mapping</button> >- </div> >- </li> >- <li> >- <label for="matchpoint">Matchpoint: </label> >- <select name="matchpoint" id="matchpoint"> >- <option value="email">Email</option> >- <option value="userid">User ID</option> >- <option value="cardnumber">Card number</option> >- </select> >- <div class="hint">Koha patron's field that will be used to match provider's user with Koha's</div> >- <div class="hint">It must be present in mapping</div> >- </li> >- <li> >- <label for="icon_url">Icon URL: </label> >- <input type="text" name="icon_url" id="icon_url" size="60" /> >- </li> >- </ol> >- </fieldset> >- >- <fieldset class="rows"> >- <legend id="identity_provider_domain">Domain configuration</legend> >- <ol> >- <li> >- <label for="domain" class="required">Domain: </label> >- <input type="text" name="domain" id="domain" class="required" size="60" /> >- <span class="required">Required</span> >- <div class="hint">Use * for any domain. You can add new domains later on the dedicated admin page.</div> >- </li> >- <li> >- <label for="default_library_id">Default library: </label> >- <select id="default_library_id" name="default_library_id" class="mandatory"> >- [% PROCESS options_for_libraries libraries => Branches.all( unfiltered => 1, do_not_select_my_library => 1 ) %] >- </select> >- <span class="required">Required</span> >- <div class="hint">Use this library for the patron on auto register</div> >- </li> >- <li> >- <label for="default_category_id">Default category: </label> >- [% SET categories = Categories.all() %] >- <select name="default_category_id" id="default_category_id" class="mandatory"> >- [% FOREACH category IN categories %] >- <option value="[% category.categorycode | html %]">[% category.description | html %]</option> >- [% END %] >- </select> >- <span class="required">Required</span> >- <div class="hint">Use this category for the patron on auto register</div> >- </li> >- <li> >- <label for="allow_opac">Allow OPAC: </label> >- <select name="allow_opac" id="allow_opac"> >- <option value="1">Yes</option> >- <option value="0" selected="selected">No</option> >- </select> >- <div class="hint">Allow users of this domain to log into the OPAC using this identity provider.</div> >- </li> >- <li> >- <label for="allow_staff">Allow staff: </label> >- <select name="allow_staff" id="allow_staff"> >- <option value="1">Yes</option> >- <option value="0" selected="selected">No</option> >- </select> >- <div class="hint">Allow staff access to users from this domain to login with this identity provider.</div> >- </li> >- <li> >- <label for="auto_register_opac">Auto register (OPAC): </label> >- <select name="auto_register_opac" id="auto_register_opac"> >- <option value="1">Allow</option> >- <option value="0" selected="selected">Don't allow</option> >- </select> >- <span>users to auto register on login (OPAC)</span> >- </li> >- <li> >- <label for="auto_register_staff">Auto register (Staff interface): </label> >- <select name="auto_register_staff" id="auto_register_staff"> >- <option value="1">Allow</option> >- <option value="0" selected="selected">Don't allow</option> >- </select> >- <span>users to auto register on login (Staff interface)</span> >- </li> >- <li> >- <label for="update_on_auth">Update on login: </label> >- <select name="update_on_auth" id="update_on_auth"> >- <option value="1">Yes</option> >- <option value="0" selected="selected">No</option> >- </select> >- <div class="hint">Update user data on login.</div> >- </li></ol >- > >- </fieldset> >- >- <fieldset class="action"> >- <input type="submit" value="Submit" /> >- <a class="cancel" href="/cgi-bin/koha/admin/identity_providers.pl">Cancel</a> >- </fieldset> >- </form> >- [% END %] >- >- [% IF op == 'edit_form' %] >- <h1>[% tx("Modify identity provider '{id_provider}'", {id_provider = identity_provider.code}) | html %]</h1> >- >- <form action="/cgi-bin/koha/admin/identity_providers.pl" id="edit_save" name="edit_save" class="validated" method="post"> >- [% INCLUDE 'csrf-token.inc' %] >- <input type="hidden" name="op" value="cud-edit_save" /> >- <input type="hidden" name="identity_provider_id" value="[%- identity_provider.identity_provider_id | html -%]" /> >- <fieldset class="rows"> >- <legend id="identity_provider_basic">Basic configuration</legend> >- <ol> >- <li> >- <label for="code" class="required">Code: </label> >- <input type="text" name="code" id="code" size="60" class="required" required="required" value="[%- identity_provider.code | html -%]" /> >- <span class="required">Required</span> >- <div class="hint">Code that identifies this provider. Only alphanumeric and "_" characters are allowed</div> >- </li> >- <li> >- <label for="description" class="required">Description: </label> >- <input type="text" name="description" id="description" size="60" class="required" required="required" value="[%- identity_provider.description | html -%]" /> >- <span class="required">Required</span> >- <div class="hint">User friendly name of this provider</div> >- </li> >- <li> >- <label for="protocol">Protocol: </label> >- <select name="protocol" id="protocol"> >- [% IF identity_provider.protocol == 'OAuth' %] >- <option value="OAuth" selected="selected">OAuth</option> >- <option value="OIDC">OIDC</option> >- <!-- Not implemented yet >- <option value="LDAP">LDAP</option> >- <option value="CAS">CAS</option> >- --> >- [% ELSE %] >- <option value="OAuth">OAuth</option> >- <option value="OIDC" selected="selected">OIDC</option> >- <!-- Not implemented yet >- <option value="LDAP">LDAP</option> >- <option value="CAS">CAS</option> >- --> >- [% END %] >- </select> >- <div class="hint">Choose the protocol this external identity provider uses</div> >- </li> >- </ol> >- </fieldset> >- >- <fieldset class="rows"> >- <legend id="identity_provider_advanced">Advanced configuration</legend> >- <ol> >- <li> >- <label for="config" class="required json">Configuration: </label> >- <textarea name="config" id="config" class="required" cols="75" rows="10">[%- identity_provider.config | html -%]</textarea> >- <span class="required">Required</span> >- <div class="hint" >- >Provider's main configuration. <button class="more btn btn-light" data-target="config"><i class="fa fa-caret-down"></i> [% tp("Show more information", "More") | html %]</button></div >- > >- <div class="hint more-config" style="display: none"> >- <div>This configuration differs for each protocol.</div> >- <div>It is recommended to add the default configuration, and then replace with appropriate values</div> >- </div> >- <div class="hint"> >- <button class="btn btn-light defaults" data-default-target="config" id="default-config">Add default [%- identity_provider.protocol | html -%] configuration</button> >- </div> >- </li> >- <li> >- <label for="mapping" class="required json">Mapping: </label> >- <textarea name="mapping" id="mapping" class="required" cols="75" rows="10">[%- identity_provider.mapping | html -%]</textarea> >- <span class="required">Required</span> >- <div class="hint" >- >Map provider's result to Koha patron's fields. <button class="more btn btn-light" data-target="mapping"><i class="fa fa-caret-down"></i> [% tp("Show more information", "More") | html %]</button></div >- > >- <div class="hint more-mapping" style="display: none"> >- <div>It is recommended to add the default mapping, and then modify to suit this provider's response</div> >- <div>Keys represent Koha's fields, and values represent the keys in provider's result</div> >- <div>For nested values in provider's results, you can use dot separation.</div> >- <div>For example, <i>firstname: "users.0.name"</i> will match the 'name' attribute of the first object in the array named 'users', and place it in 'firstname' field</div> >- <div>If you plan to use auto register feature, either <i>userid</i> or <i>cardnumber</i> must be present in this mapping</div> >- </div> >- <div class="hint"> >- <button class="btn btn-light defaults" data-default-target="mapping" id="default-mapping">Add default [%- identity_provider.protocol | html -%] mapping</button> >- </div> >- </li> >- <li> >- <label for="matchpoint">Matchpoint: </label> >- <select name="matchpoint" id="matchpoint"> >- [%- IF identity_provider.matchpoint == 'email' -%] >- <option value="email" selected="selected">Email</option> >- [%- ELSE -%] >- <option value="email">Email</option> >- [%- END -%] >- [%- IF identity_provider.matchpoint == 'userid' -%] >- <option value="userid" selected="selected">User id</option> >- [%- ELSE -%] >- <option value="userid">User id</option> >- [%- END -%] >- [%- IF identity_provider.matchpoint == 'cardnumber' -%] >- <option value="cardnumber" selected="selected">Card number</option> >- [%- ELSE -%] >- <option value="cardnumber">Card number</option> >- [%- END -%] >- </select> >- <div class="hint">Koha patron's field that will be used to match provider's user with Koha's</div> >- <div class="hint">It must be present in mapping</div> >- </li> >- <li> >- <label for="icon_url">Icon URL: </label> >- <input type="text" name="icon_url" id="icon_url" size="60" value="[%- identity_provider.icon_url | html -%]" /> >- </li> >- </ol> >- </fieldset> >- <fieldset class="action"> >- <input type="submit" value="Submit" /> >- <a class="cancel" href="/cgi-bin/koha/admin/identity_providers.pl">Cancel</a> >- </fieldset> >- </form> >- [% END %] >- >- [% IF op == 'list' %] >- <div id="toolbar" class="btn-toolbar"> >- <a class="btn btn-default" id="new_identity_provider" href="/cgi-bin/koha/admin/identity_providers.pl?op=add_form"><i class="fa fa-plus"></i> New identity provider</a> >- </div> >- >- <h1>Identity providers</h1> >- <div class="page-section"> >- <table id="identity_providers"> >- <thead> >- <tr> >- <th>Code</th> >- <th>Description</th> >- <th>Protocol</th> >- <th data-class-name="actions no-export">Actions</th> >- </tr> >- </thead> >- </table> >- </div> >- [% END %] >- >- <div id="delete_confirm_modal" class="modal" tabindex="-1" role="dialog" aria-labelledby="delete_confirm_modal_label" aria-hidden="true"> >- <div class="modal-dialog"> >- <div class="modal-content"> >- <div class="modal-header"> >- <h1 class="modal-title" id="delete_confirm_modal_label">Confirm deletion of identity provider</h1> >- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> >- </div> >- <div class="modal-body"> >- <div id="delete_confirm_dialog"></div> >- </div> >- <div class="modal-footer"> >- <a href="#" class="btn btn-danger" id="delete_confirm_modal_button" role="button" data-bs-toggle="modal">Delete</a> >- <button type="button" class="btn btn-default" data-bs-dismiss="modal">Close</button> >- </div> >- </div> >- <!-- /.modal-content --> >- </div> >- <!-- /.modal-dialog --> >- </div> >- <!-- #delete_confirm_modal --> >-[% END %] >+ <div id="identity-providers"></div> >+[% END #/ WRAPPER main-container.inc %] > > [% MACRO jsinclude BLOCK %] >- [% Asset.js("js/admin-menu.js") | $raw %] >+ [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'datatables.inc' %] >+ [% Asset.js("js/admin-menu.js") | $raw %] > <script> >- $(document).ready(function () { >- var identity_providers_url = "/api/v1/auth/identity_providers"; >- window.identity_providers = $("#identity_providers").kohaTable({ >- ajax: { >- url: identity_providers_url, >- }, >- language: { >- emptyTable: '<div class="alert alert-info">' + _("There are no identity providers defined.") + "</div>", >- }, >- columnDefs: [ >- { >- targets: [0, 1, 2], >- render: function (data, type, row, meta) { >- if (type == "display") { >- if (data != null) { >- return data.escapeHtml(); >- } else { >- return "Default"; >- } >- } >- return data; >- }, >- }, >- ], >- columns: [ >- { >- data: "code", >- searchable: true, >- orderable: true, >- }, >- { >- data: "description", >- searchable: true, >- orderable: true, >- }, >- { >- data: "protocol", >- searchable: true, >- orderable: true, >- }, >- { >- data: function (row, type, val, meta) { >- var result = >- '<a class="btn btn-default btn-xs" role="button" href="/cgi-bin/koha/admin/identity_providers.pl?op=edit_form&identity_provider_id=' + >- encodeURIComponent(row.identity_provider_id) + >- '"><i class="fa-solid fa-pencil" aria-hidden="true"></i> ' + >- _("Edit") + >- "</a>" + >- "\n"; >- result += >- '<a class="btn btn-default btn-xs delete_identity_provider" role="button" href="#" data-bs-toggle="modal" data-bs-target="#delete_confirm_modal" data-auth-provider-id="' + >- encodeURIComponent(row.identity_provider_id) + >- '" data-auth-provider-code="' + >- encodeURIComponent(row.code.escapeHtml()) + >- '"><i class="fa fa-trash-can" aria-hidden="true"></i> ' + >- _("Delete") + >- "</a>" + >- "\n"; >- result += >- '<a class="btn btn-default btn-xs edit_domains" role="button" href="/cgi-bin/koha/admin/identity_providers.pl?domain_ops=1&identity_provider_id=' + >- encodeURIComponent(row.identity_provider_id) + >- '"><i class="fa fa-cog" aria-hidden="true"></i> ' + >- _("Manage domains") + >- "</a>"; >- return result; >- }, >- searchable: false, >- orderable: false, >- }, >- ], >- createdRow: function (row, data, dataIndex) { >- if (data.debug) { >- $(row).addClass("debug"); >- } >- }, >- }); >- >- $("#identity_providers").on("click", ".delete_identity_provider", function () { >- var identity_provider_id = $(this).data("auth-provider-id"); >- var identity_provider_code = decodeURIComponent($(this).data("auth-provider-code")); >- >- $("#delete_confirm_dialog").html(_("You are about to delete the '%s' identity provider.").format(identity_provider_code)); >- $("#delete_confirm_modal_button").data("auth-provider-id", identity_provider_id); >- $("#delete_confirm_modal_button").data("auth-provider-code", identity_provider_code); >- }); >- >- $("#delete_confirm_modal_button").on("click", function () { >- var identity_provider_id = $(this).data("auth-provider-id"); >- var identity_provider_code = $(this).data("auth-provider-code"); >- >- $.ajax({ >- method: "DELETE", >- url: identity_providers_url + "/" + identity_provider_id, >- }) >- .success(function () { >- window.identity_providers.api().ajax.reload(function (data) { >- $("#identity_provider_action_result_dialog").hide(); >- $("#identity_provider_delete_success").html(_("Server '%s' deleted successfully.").format(identity_provider_code)).show(); >- }); >- }) >- .fail(function () { >- $("#identity_provider_delete_error").html(_("Error deleting server '%s'. Check the logs for details.").format(identity_provider_code)).show(); >- }) >- .done(function () { >- $("#delete_confirm_modal").modal("hide"); >- }); >- }); >- >- $.validator.addMethod( >- "json", >- function (value, element) { >- if (this.optional(element) && value === "") return true; >- try { >- JSON.parse(value); >- } catch (error) { >- return false; >- } >- return true; >- }, >- _("Not a valid JSON") >- ); >- >- $.validator.addMethod( >- "alphanum", >- function (value, element) { >- if (this.optional(element) && value === "") return true; >- return /^[a-zA-Z0-9_]+$/.test(value); >- }, >- _("Value must have alphanumeric characters or '_'") >- ); >- >- $("#config, #mapping").each(function () { >- $(this).rules("add", { >- required: true, >- json: true, >- }); >- }); >- >- $("button.more").on("click", function (event) { >- event.preventDefault(); >- var target = $(this).hide().data("target"); >- $(".more-" + target).show(); >- }); >- >- $("#code").rules("add", { >- alphanum: true, >- required: true, >- }); >- >- var defaults = { >- OIDC: { >- config: { >- key: "<enter client id>", >- secret: "<enter client secret>", >- well_known_url: "<enter openid configuration endpoint>", >- scope: "openid email", >- }, >- mapping: { >- email: "email", >- firstname: "given_name", >- surname: "family_name", >- }, >- }, >- OAuth: { >- config: { >- key: "<enter client id>", >- secret: "<enter client secret>", >- authorize_url: "<enter authorization endpoint>", >- token_url: "<enter token endpoint>", >- userinfo_url: "<enter user info endpoint (optional)>", >- scope: "email", >- }, >- mapping: { >- email: "email", >- firstname: "given_name", >- surname: "family_name", >- }, >- }, >- }; >- >- $("#protocol").on("change", function () { >- var protocol = $(this).val(); >- $("#default-config").html(_("Add default %s configuration").format(protocol)); >- $("#default-mapping").html(_("Add default %s mapping").format(protocol)); >- }); >- >- $("button.defaults").on("click", function (event) { >- event.preventDefault(); >- var target = $(this).data("defaultTarget"); >- if ($("#" + target).val() !== "" && !confirm(_("Are you sure you want to replace current %s contents?").format(target))) { >- return; >- } >- var protocol = $("#protocol").val(); >- $("#" + target).val(JSON.stringify(defaults[protocol][target], null, 2)); >- }); >- }); >+ const logged_in_user = [% To.json(logged_in_user.unblessed) | $raw %]; >+ window.borrower_columns = [% To.json(borrower_columns) | $raw %]; >+ window.libraries_map = [% To.json(libraries_map) | $raw %]; >+ window.categories_map = [% To.json(categories_map) | $raw %]; >+ window.idp_default_hostnames = [% To.json(idp_default_hostnames) | $raw %]; > </script> >+ [% Asset.js("js/vue/dist/admin/identity_providers.js") | $raw %] > [% END %] >- > [% INCLUDE 'intranet-bottom.inc' %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref >index 37202dc17c6..db3f9cae2b1 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/opac.pref >@@ -1015,9 +1015,3 @@ OPAC: > - "fields (separate values with |). Tabs appear in the order listed.<br/>" > - "<em>Currently supported values</em>: Item types (<strong>itemtypes</strong>), Collection, (<strong>ccode</strong>) and Shelving location (<strong>loc</strong>)." > Authentication: >- - >- - pref: OPACShibOnly >- choices: >- 1: "Don't allow" >- 0: Allow >- - patrons to login by means other than Shibboleth. >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/staff_interface.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/staff_interface.pref >index dbd3a877a32..2c321a3fe9f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/staff_interface.pref >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/staff_interface.pref >@@ -204,12 +204,6 @@ Staff interface: > - 'records when downloading from the reports module.<br>NOTE: Only a positive value will enforce a limit. A specific limit in the report overrides this setting.' > > Authentication: >- - >- - pref: staffShibOnly >- choices: >- 1: "Don't allow" >- 0: Allow >- - staff to log in by means other than Shibboleth. > - > - pref: TwoFactorAuthentication > choices: >diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >index 918947db605..8779b53848e 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/api-client.js >@@ -197,6 +197,9 @@ export const APIClient = { > patron: createClientProxy(() => import("./patron-api-client.js")), > patron_list: createClientProxy(() => import("./patron-list-api-client.js")), > recall: createClientProxy(() => import("./recall-api-client.js")), >+ identity_providers: createClientProxy( >+ () => import("./identity-providers-api-client.js") >+ ), > sysprefs: createClientProxy( > () => import("./system-preferences-api-client.js") > ), >diff --git a/koha-tmpl/intranet-tmpl/prog/js/fetch/identity-providers-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/fetch/identity-providers-api-client.js >new file mode 100644 >index 00000000000..c77af6d2d90 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/fetch/identity-providers-api-client.js >@@ -0,0 +1,171 @@ >+export class IdentityProvidersAPIClient { >+ constructor(HttpClient) { >+ this.httpClient = new HttpClient({ >+ baseURL: "/api/v1/auth/identity_providers/", >+ }); >+ this.allHostnamesHttpClient = new HttpClient({ >+ baseURL: "/api/v1/auth/hostnames/", >+ }); >+ } >+ >+ get providers() { >+ return { >+ getAll: params => >+ this.httpClient.getAll({ >+ endpoint: "", >+ params, >+ }), >+ get: id => >+ this.httpClient.get({ >+ endpoint: id, >+ headers: { >+ "x-koha-embed": "domains,hostnames,mappings", >+ }, >+ }), >+ create: provider => >+ this.httpClient.post({ >+ endpoint: "", >+ body: provider, >+ }), >+ update: (provider, id) => >+ this.httpClient.put({ >+ endpoint: id, >+ body: provider, >+ }), >+ delete: id => >+ this.httpClient.delete({ >+ endpoint: id, >+ }), >+ count: (query = {}) => >+ this.httpClient.count({ >+ endpoint: >+ "?" + >+ new URLSearchParams({ >+ _page: 1, >+ _per_page: 1, >+ ...(query && { q: JSON.stringify(query) }), >+ }), >+ }), >+ }; >+ } >+ >+ get mappings() { >+ return { >+ getAll: (providerId, params) => >+ this.httpClient.getAll({ >+ endpoint: `${providerId}/mappings`, >+ params, >+ }), >+ get: (providerId, mappingId) => >+ this.httpClient.get({ >+ endpoint: `${providerId}/mappings/${mappingId}`, >+ }), >+ create: (providerId, mapping) => >+ this.httpClient.post({ >+ endpoint: `${providerId}/mappings`, >+ body: mapping, >+ }), >+ update: (providerId, mapping, mappingId) => >+ this.httpClient.put({ >+ endpoint: `${providerId}/mappings/${mappingId}`, >+ body: mapping, >+ }), >+ delete: (providerId, mappingId) => >+ this.httpClient.delete({ >+ endpoint: `${providerId}/mappings/${mappingId}`, >+ }), >+ count: (providerId, query = {}) => >+ this.httpClient.count({ >+ endpoint: >+ `${providerId}/mappings?` + >+ new URLSearchParams({ >+ _page: 1, >+ _per_page: 1, >+ ...(query && { q: JSON.stringify(query) }), >+ }), >+ }), >+ }; >+ } >+ >+ get hostnames() { >+ return { >+ getAll: (providerId, params) => >+ this.httpClient.getAll({ >+ endpoint: `${providerId}/hostnames`, >+ params, >+ }), >+ get: (providerId, hostnameId) => >+ this.httpClient.get({ >+ endpoint: `${providerId}/hostnames/${hostnameId}`, >+ }), >+ create: (providerId, hostname) => >+ this.httpClient.post({ >+ endpoint: `${providerId}/hostnames`, >+ body: hostname, >+ }), >+ update: (providerId, hostname, hostnameId) => >+ this.httpClient.put({ >+ endpoint: `${providerId}/hostnames/${hostnameId}`, >+ body: hostname, >+ }), >+ delete: (providerId, hostnameId) => >+ this.httpClient.delete({ >+ endpoint: `${providerId}/hostnames/${hostnameId}`, >+ }), >+ }; >+ } >+ >+ get allHostnames() { >+ return { >+ getAll: params => >+ this.allHostnamesHttpClient.getAll({ >+ endpoint: "", >+ params, >+ }), >+ get: id => >+ this.allHostnamesHttpClient.get({ >+ endpoint: id, >+ }), >+ }; >+ } >+ >+ get domains() { >+ return { >+ getAll: (providerId, params) => >+ this.httpClient.getAll({ >+ endpoint: `${providerId}/domains`, >+ params, >+ }), >+ get: (providerId, domainId) => >+ this.httpClient.get({ >+ endpoint: `${providerId}/domains/${domainId}`, >+ }), >+ create: (providerId, domain) => >+ this.httpClient.post({ >+ endpoint: `${providerId}/domains`, >+ body: domain, >+ }), >+ update: (providerId, domain, domainId) => >+ this.httpClient.put({ >+ endpoint: `${providerId}/domains/${domainId}`, >+ body: domain, >+ }), >+ delete: (providerId, domainId) => >+ this.httpClient.delete({ >+ endpoint: `${providerId}/domains/${domainId}`, >+ }), >+ count: (providerId, query = {}) => >+ this.httpClient.count({ >+ endpoint: >+ `${providerId}/domains?` + >+ new URLSearchParams({ >+ _page: 1, >+ _per_page: 1, >+ ...(query && { q: JSON.stringify(query) }), >+ }), >+ }), >+ }; >+ } >+} >+ >+export default IdentityProvidersAPIClient; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/DomainResource.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/DomainResource.vue >new file mode 100644 >index 00000000000..641398db5f4 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/DomainResource.vue >@@ -0,0 +1,261 @@ >+<template> >+ <div> >+ <BaseResource :routeAction="routeAction" :instancedResource="this" /> >+ </div> >+</template> >+ >+<script> >+import { ref, computed } from "vue"; >+import { useRoute } from "vue-router"; >+import BaseResource from "./../BaseResource.vue"; >+import { useBaseResource } from "../../composables/base-resource.js"; >+import { APIClient } from "../../fetch/api-client.js"; >+import { $__ } from "@koha-vue/i18n"; >+ >+export default { >+ name: "DomainResource", >+ components: { BaseResource }, >+ props: { >+ routeAction: String, >+ }, >+ emits: ["select-resource"], >+ setup(props) { >+ const route = useRoute(); >+ const providerId = computed(() => route.params.identity_provider_id); >+ >+ const getLibraries = () => window.libraries_map || []; >+ const getCategories = () => window.categories_map || []; >+ >+ const resourceAttrs = [ >+ { >+ name: "domain", >+ type: "text", >+ label: __("Domain"), >+ group: "Domain", >+ toolTip: __( >+ "Email domain to match. Use '*' or leave empty for any domain. Wildcards like '*library.com' are supported." >+ ), >+ }, >+ { >+ name: "allow_opac", >+ type: "boolean", >+ label: __("Allow OPAC login"), >+ group: "Access", >+ toolTip: __("Allow users of this domain to log into the OPAC"), >+ }, >+ { >+ name: "allow_staff", >+ type: "boolean", >+ label: __("Allow staff login"), >+ group: "Access", >+ toolTip: __( >+ "Allow users of this domain to log into the staff interface" >+ ), >+ }, >+ { >+ name: "auto_register_opac", >+ type: "boolean", >+ label: __("Auto-register (OPAC)"), >+ group: "Auto-registration", >+ toolTip: __( >+ "Automatically create patron records for new OPAC users from this domain" >+ ), >+ }, >+ { >+ name: "auto_register_staff", >+ type: "boolean", >+ label: __("Auto-register (staff)"), >+ group: "Auto-registration", >+ toolTip: __( >+ "Automatically create patron records for new staff users from this domain" >+ ), >+ }, >+ { >+ name: "update_on_auth", >+ type: "boolean", >+ label: __("Update patron data on login"), >+ group: "Auto-registration", >+ toolTip: __( >+ "Sync patron attributes from the provider on each login" >+ ), >+ }, >+ { >+ name: "default_library_id", >+ type: "select", >+ label: __("Default library"), >+ group: "Auto-registration defaults", >+ options: getLibraries(), >+ requiredKey: "value", >+ selectLabel: "label", >+ toolTip: __("Library assigned to auto-registered patrons"), >+ }, >+ { >+ name: "default_category_id", >+ type: "select", >+ label: __("Default category"), >+ group: "Auto-registration defaults", >+ options: getCategories(), >+ requiredKey: "value", >+ selectLabel: "label", >+ toolTip: __( >+ "Patron category assigned to auto-registered patrons" >+ ), >+ }, >+ ]; >+ >+ const baseResource = useBaseResource({ >+ resourceName: "domain", >+ nameAttr: "domain", >+ idAttr: "identity_provider_domain_id", >+ components: { >+ show: "DomainShow", >+ list: "DomainsList", >+ add: "DomainsFormAdd", >+ edit: "DomainsFormEdit", >+ }, >+ apiClient: { >+ getAll: params => >+ APIClient.identity_providers.domains.getAll( >+ providerId.value, >+ params >+ ), >+ get: id => >+ APIClient.identity_providers.domains.get( >+ providerId.value, >+ id >+ ), >+ create: domain => >+ APIClient.identity_providers.domains.create( >+ providerId.value, >+ domain >+ ), >+ update: (d, id) => >+ APIClient.identity_providers.domains.update( >+ providerId.value, >+ d, >+ id >+ ), >+ delete: id => >+ APIClient.identity_providers.domains.delete( >+ providerId.value, >+ id >+ ), >+ count: (query = {}) => >+ APIClient.identity_providers.domains.count( >+ providerId.value, >+ query >+ ), >+ }, >+ i18n: { >+ deleteConfirmationMessage: $__( >+ "Are you sure you want to remove this domain configuration?" >+ ), >+ deleteSuccessMessage: $__("Domain deleted"), >+ displayName: $__("Domain"), >+ editLabel: $__("Edit domain"), >+ emptyListMessage: $__( >+ "There are no domains configured for this provider" >+ ), >+ newLabel: $__("New domain"), >+ }, >+ table: { >+ resourceTableUrl: `/api/v1/auth/identity_providers/${providerId.value}/domains`, >+ options: {}, >+ }, >+ stickyToolbar: ["Form"], >+ embedded: props.embedded, >+ formGroupsDisplayMode: "accordion", >+ resourceAttrs, >+ props, >+ moduleStore: "IdentityProvidersStore", >+ }); >+ >+ const getResourceShowURL = id => >+ baseResource.router.resolve({ >+ name: "DomainShow", >+ params: { >+ identity_provider_id: providerId.value, >+ identity_provider_domain_id: id, >+ }, >+ }).href; >+ >+ const goToResourceAdd = () => >+ baseResource.router.push({ >+ name: "DomainsFormAdd", >+ params: { identity_provider_id: providerId.value }, >+ }); >+ >+ const goToResourceEdit = resource => >+ baseResource.router.push({ >+ name: "DomainsFormEdit", >+ params: { >+ identity_provider_id: providerId.value, >+ identity_provider_domain_id: >+ resource.identity_provider_domain_id, >+ }, >+ }); >+ >+ const goToResourceList = () => >+ baseResource.router.push({ >+ name: "ProviderShow", >+ params: { identity_provider_id: providerId.value }, >+ }); >+ >+ const onFormSave = (e, domainToSave) => { >+ e.preventDefault(); >+ const domain = JSON.parse(JSON.stringify(domainToSave)); >+ const domainId = domain.identity_provider_domain_id; >+ >+ delete domain.identity_provider_domain_id; >+ delete domain.identity_provider_id; >+ >+ if (domainId) { >+ return baseResource.apiClient.update(domain, domainId).then( >+ updatedDomain => { >+ baseResource.setMessage($__("Domain updated")); >+ baseResource.router.push({ >+ name: "ProviderShow", >+ params: { >+ identity_provider_id: providerId.value, >+ }, >+ }); >+ return updatedDomain; >+ }, >+ error => {} >+ ); >+ } else { >+ return baseResource.apiClient.create(domain).then( >+ newDomain => { >+ baseResource.setMessage($__("Domain created")); >+ baseResource.router.push({ >+ name: "ProviderShow", >+ params: { >+ identity_provider_id: providerId.value, >+ }, >+ }); >+ return newDomain; >+ }, >+ error => {} >+ ); >+ } >+ }; >+ >+ const tableOptions = { >+ url: baseResource.getResourceTableUrl(), >+ actions: { >+ "-1": ["edit", "delete"], >+ }, >+ }; >+ >+ return { >+ ...baseResource, >+ getResourceShowURL, >+ goToResourceAdd, >+ goToResourceEdit, >+ goToResourceList, >+ tableOptions, >+ onFormSave, >+ }; >+ }, >+}; >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/Main.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/Main.vue >new file mode 100644 >index 00000000000..852a82ff178 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/Main.vue >@@ -0,0 +1,76 @@ >+<template> >+ <div v-if="initialized"> >+ <Teleport to="#vue-breadcrumbs-container"> >+ <Breadcrumbs /> >+ </Teleport> >+ <Dialog /> >+ <router-view /> >+ </div> >+</template> >+ >+<script> >+import { inject, onBeforeMount, ref } from "vue"; >+import Breadcrumbs from "../Breadcrumbs.vue"; >+import Dialog from "../Dialog.vue"; >+import "vue-select/dist/vue-select.css"; >+ >+export default { >+ setup() { >+ const mainStore = inject("mainStore"); >+ const { loading, loaded } = mainStore; >+ >+ const initialized = ref(false); >+ >+ onBeforeMount(() => { >+ loading(); >+ setTimeout(() => { >+ loaded(); >+ initialized.value = true; >+ }, 0); >+ }); >+ >+ return { initialized }; >+ }, >+ components: { >+ Breadcrumbs, >+ Dialog, >+ }, >+}; >+</script> >+ >+<style> >+#menu ul ul, >+#navmenulist ul ul { >+ padding-left: 2em; >+ font-size: 100%; >+} >+ >+form .v-select { >+ display: inline-block; >+ background-color: white; >+ width: 30%; >+} >+ >+.v-select, >+input:not([type="submit"]):not([type="search"]):not([type="button"]):not( >+ [type="checkbox"] >+ ):not([type="radio"]), >+textarea { >+ border-color: rgba(60, 60, 60, 0.26); >+ border-width: 1px; >+ border-radius: 4px; >+ min-width: 30%; >+} >+ >+#navmenulist ul li a.current.disabled { >+ background-color: inherit; >+ border-left: 5px solid #e6e6e6; >+ color: #000; >+} >+ >+#navmenulist ul li a.disabled { >+ color: #666; >+ pointer-events: none; >+ font-weight: 700; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/MappingResource.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/MappingResource.vue >new file mode 100644 >index 00000000000..9854b97a424 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/MappingResource.vue >@@ -0,0 +1,221 @@ >+<template> >+ <div> >+ <BaseResource :routeAction="routeAction" :instancedResource="this" /> >+ </div> >+</template> >+ >+<script> >+import { ref, onMounted, computed } from "vue"; >+import { useRoute } from "vue-router"; >+import BaseResource from "./../BaseResource.vue"; >+import { useBaseResource } from "../../composables/base-resource.js"; >+import { APIClient } from "../../fetch/api-client.js"; >+import { $__ } from "@koha-vue/i18n"; >+ >+export default { >+ name: "MappingResource", >+ components: { BaseResource }, >+ props: { >+ routeAction: String, >+ }, >+ emits: ["select-resource"], >+ setup(props) { >+ const route = useRoute(); >+ const providerId = computed(() => route.params.identity_provider_id); >+ >+ const getBorrowerColumns = () => window.borrower_columns || []; >+ const borrowerColumnsArray = getBorrowerColumns(); >+ >+ const resourceAttrs = [ >+ { >+ name: "koha_field", >+ required: true, >+ type: "select", >+ options: borrowerColumnsArray, >+ requiredKey: "value", >+ selectLabel: "label", >+ label: __("Koha field"), >+ group: "Mapping", >+ toolTip: __( >+ "The field in the Koha borrowers table to populate" >+ ), >+ }, >+ { >+ name: "provider_field", >+ type: "text", >+ label: __("Provider attribute"), >+ group: "Mapping", >+ toolTip: __( >+ "The attribute name supplied by the identity provider. Leave empty to use only the default value." >+ ), >+ }, >+ { >+ name: "default_content", >+ type: "text", >+ label: __("Default value"), >+ group: "Mapping", >+ toolTip: __( >+ "Value to use when the provider does not supply this attribute" >+ ), >+ }, >+ { >+ name: "is_matchpoint", >+ type: "boolean", >+ label: __("Use as matchpoint"), >+ group: "Mapping", >+ toolTip: __( >+ "Match incoming users against this field. Only one mapping per provider may be the matchpoint." >+ ), >+ }, >+ ]; >+ >+ const baseResource = useBaseResource({ >+ resourceName: "mapping", >+ nameAttr: "koha_field", >+ idAttr: "mapping_id", >+ components: { >+ show: "MappingShow", >+ list: "MappingsList", >+ add: "MappingsFormAdd", >+ edit: "MappingsFormEdit", >+ }, >+ apiClient: { >+ getAll: params => >+ APIClient.identity_providers.mappings.getAll( >+ providerId.value, >+ params >+ ), >+ get: id => >+ APIClient.identity_providers.mappings.get( >+ providerId.value, >+ id >+ ), >+ create: mapping => >+ APIClient.identity_providers.mappings.create( >+ providerId.value, >+ mapping >+ ), >+ update: (m, id) => >+ APIClient.identity_providers.mappings.update( >+ providerId.value, >+ m, >+ id >+ ), >+ delete: id => >+ APIClient.identity_providers.mappings.delete( >+ providerId.value, >+ id >+ ), >+ count: q => >+ APIClient.identity_providers.mappings.count( >+ providerId.value, >+ q >+ ), >+ }, >+ i18n: { >+ deleteConfirmationMessage: $__( >+ "Are you sure you want to remove this field mapping?" >+ ), >+ deleteSuccessMessage: $__("Mapping deleted"), >+ displayName: $__("Field mapping"), >+ editLabel: $__("Edit field mapping"), >+ emptyListMessage: $__( >+ "There are no field mappings defined for this provider" >+ ), >+ newLabel: $__("New field mapping"), >+ }, >+ table: { >+ resourceTableUrl: `/api/v1/auth/identity_providers/${providerId.value}/mappings`, >+ options: {}, >+ }, >+ stickyToolbar: ["Form"], >+ embedded: props.embedded, >+ formGroupsDisplayMode: "accordion", >+ resourceAttrs, >+ props, >+ moduleStore: "IdentityProvidersStore", >+ }); >+ >+ const getResourceShowURL = id => >+ baseResource.router.resolve({ >+ name: "MappingShow", >+ params: { >+ identity_provider_id: providerId.value, >+ identity_provider_mapping_id: id, >+ }, >+ }).href; >+ >+ const goToResourceAdd = () => >+ baseResource.router.push({ >+ name: "MappingsFormAdd", >+ params: { identity_provider_id: providerId.value }, >+ }); >+ >+ const goToResourceEdit = resource => >+ baseResource.router.push({ >+ name: "MappingsFormEdit", >+ params: { >+ identity_provider_id: providerId.value, >+ identity_provider_mapping_id: resource.mapping_id, >+ }, >+ }); >+ >+ const goToResourceList = () => >+ baseResource.router.push({ >+ name: "ProviderShow", >+ params: { identity_provider_id: providerId.value }, >+ }); >+ >+ const onFormSave = async (e, mappingToSave) => { >+ e.preventDefault(); >+ >+ const mapping = JSON.parse(JSON.stringify(mappingToSave)); >+ const mapping_id = mapping.mapping_id; >+ >+ delete mapping.mapping_id; >+ delete mapping.identity_provider_id; >+ >+ try { >+ if (mapping_id) { >+ await APIClient.identity_providers.mappings.update( >+ providerId.value, >+ mapping, >+ mapping_id >+ ); >+ baseResource.setMessage(__("Mapping updated")); >+ } else { >+ await APIClient.identity_providers.mappings.create( >+ providerId.value, >+ mapping >+ ); >+ baseResource.setMessage(__("Mapping created")); >+ } >+ >+ baseResource.router.push({ >+ name: "ProviderShow", >+ params: { identity_provider_id: providerId.value }, >+ }); >+ } catch (error) { >+ // Errors handled by base resource >+ } >+ }; >+ >+ const tableOptions = { >+ url: baseResource.getResourceTableUrl(), >+ actions: { >+ "-1": ["edit", "delete"], >+ }, >+ }; >+ >+ return { >+ ...baseResource, >+ getResourceShowURL, >+ goToResourceAdd, >+ goToResourceEdit, >+ goToResourceList, >+ tableOptions, >+ onFormSave, >+ }; >+ }, >+}; >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/ProviderResource.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/ProviderResource.vue >new file mode 100644 >index 00000000000..b2b09c482c0 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/IdentityProviders/ProviderResource.vue >@@ -0,0 +1,840 @@ >+<template> >+ <div v-if="!initialized">{{ $__("Loading") }}</div> >+ <BaseResource v-else :routeAction="routeAction" :instancedResource="this" /> >+</template> >+ >+<script> >+import { ref, onMounted, reactive, watch } from "vue"; >+import BaseResource from "./../BaseResource.vue"; >+import { useBaseResource } from "../../composables/base-resource.js"; >+import { APIClient } from "../../fetch/api-client.js"; >+import { $__ } from "@koha-vue/i18n"; >+ >+const PROTOCOL_CONFIG_FIELDS = { >+ OAuth: [ >+ { >+ name: "key", >+ label: __("Client ID"), >+ required: true, >+ type: "text", >+ group: "OAuth settings", >+ }, >+ { >+ name: "secret", >+ label: __("Client secret"), >+ required: true, >+ type: "text", >+ group: "OAuth settings", >+ }, >+ { >+ name: "authorize_url", >+ label: __("Authorization URL"), >+ required: true, >+ type: "text", >+ group: "OAuth settings", >+ }, >+ { >+ name: "token_url", >+ label: __("Token URL"), >+ required: true, >+ type: "text", >+ group: "OAuth settings", >+ }, >+ { >+ name: "userinfo_url", >+ label: __("User info URL"), >+ required: false, >+ type: "text", >+ group: "OAuth settings", >+ }, >+ { >+ name: "scope", >+ label: __("Scope"), >+ required: false, >+ type: "text", >+ group: "OAuth settings", >+ toolTip: __("Space-separated list of scopes, e.g. 'email profile'"), >+ }, >+ ], >+ OIDC: [ >+ { >+ name: "key", >+ label: __("Client ID"), >+ required: true, >+ type: "text", >+ group: "OIDC settings", >+ }, >+ { >+ name: "secret", >+ label: __("Client secret"), >+ required: true, >+ type: "text", >+ group: "OIDC settings", >+ }, >+ { >+ name: "well_known_url", >+ label: __("Well-known URL"), >+ required: true, >+ type: "text", >+ group: "OIDC settings", >+ toolTip: __( >+ "OpenID Connect discovery endpoint, e.g. https://login.example.com/.well-known/openid-configuration" >+ ), >+ }, >+ { >+ name: "scope", >+ label: __("Scope"), >+ required: false, >+ type: "text", >+ group: "OIDC settings", >+ toolTip: __( >+ "Space-separated list of scopes, e.g. 'openid email profile'" >+ ), >+ }, >+ ], >+ SAML2: [ >+ { >+ name: "saml2_sp_note", >+ type: "group_placeholder", >+ group: "SAML2 settings", >+ description: __( >+ "SAML2/Shibboleth connection is handled by the native service provider software (e.g. mod_shib) configured on this server. No Koha-side connection settings are required." >+ ), >+ }, >+ ], >+}; >+ >+export default { >+ name: "ProviderResource", >+ components: { >+ BaseResource, >+ }, >+ props: { >+ routeAction: String, >+ }, >+ emits: ["select-resource"], >+ setup(props) { >+ const initialized = ref(false); >+ >+ // Tracks the currently selected/loaded protocol so config field groups >+ // can be shown or hidden reactively via hideIn closures. >+ const selectedProtocol = ref(null); >+ >+ // Holds a reference to the reactive resource being edited/created so >+ // that onChange callbacks (e.g. is_matchpoint exclusivity) can reach >+ // sibling rows in the same relationship widget. >+ const activeResource = ref(null); >+ >+ // Tracks the IDs of sub-resources that existed when the provider was >+ // loaded, so we can delete any that the user removed during editing. >+ const originalMappingIds = ref([]); >+ const originalHostnameIds = ref([]); >+ const originalDomainIds = ref([]); >+ >+ const staticResourceAttrs = [ >+ { >+ name: "code", >+ required: true, >+ type: "text", >+ label: __("Code"), >+ group: "Basic configuration", >+ toolTip: __( >+ "Unique identifier for this provider. Alphanumeric and underscore only." >+ ), >+ }, >+ { >+ name: "description", >+ required: true, >+ type: "text", >+ label: __("Description"), >+ group: "Basic configuration", >+ toolTip: __("User-friendly name displayed on login pages"), >+ }, >+ { >+ name: "protocol", >+ required: true, >+ type: "select", >+ label: __("Protocol"), >+ group: "Basic configuration", >+ options: [ >+ { value: "OAuth", label: "OAuth" }, >+ { value: "OIDC", label: "OIDC" }, >+ { value: "SAML2", label: "SAML2 / Shibboleth" }, >+ ], >+ requiredKey: "value", >+ selectLabel: "label", >+ }, >+ { >+ name: "icon_url", >+ type: "text", >+ label: __("Icon URL"), >+ group: "Basic configuration", >+ toolTip: __( >+ "URL to an icon image shown on the OPAC login button" >+ ), >+ }, >+ { >+ name: "enabled", >+ type: "boolean", >+ label: __("Enabled"), >+ group: "Basic configuration", >+ toolTip: __( >+ "When disabled, this provider will not be shown on login pages" >+ ), >+ }, >+ ]; >+ >+ const protocolPlaceholderAttr = { >+ name: "_protocol_placeholder", >+ type: "group_placeholder", >+ group: "Protocol settings", >+ description: __( >+ "Select a protocol above to expose protocol-specific settings" >+ ), >+ hideIn: () => >+ selectedProtocol.value >+ ? ["Form", "Show", "List"] >+ : ["Show", "List"], >+ }; >+ >+ // Build config attrs for ALL protocols. Each gets a hideIn closure that >+ // hides it when a different protocol is selected, or when no protocol >+ // has been selected yet (add mode). >+ const configResourceAttrs = Object.entries( >+ PROTOCOL_CONFIG_FIELDS >+ ).flatMap(([protocol, fields]) => >+ fields.map(f => ({ >+ ...f, >+ name: `_config_${f.name}`, >+ hideIn: () => >+ !selectedProtocol.value || >+ selectedProtocol.value !== protocol >+ ? ["Form", "Show", "List"] >+ : ["List"], >+ })) >+ ); >+ >+ const borrowerColumnsArray = (window.borrower_columns || []).map( >+ col => ({ value: col.value, label: col.label }) >+ ); >+ const librariesArray = (window.libraries_map || []).map(lib => ({ >+ value: lib.value, >+ label: lib.label, >+ })); >+ const categoriesArray = (window.categories_map || []).map(cat => ({ >+ value: cat.value, >+ label: cat.label, >+ })); >+ >+ const hostnameAttr = { >+ name: "hostnames", >+ type: "relationshipWidget", >+ group: "Network & Entry Settings", >+ hideIn: ["List"], >+ showElement: { >+ type: "table", >+ columnData: "hostnames", >+ hidden: provider => !!provider.hostnames?.length, >+ columns: [ >+ { name: __("Hostname"), value: "hostname" }, >+ { name: __("Force SSO"), value: "force_sso" }, >+ ], >+ }, >+ componentProps: { >+ resourceRelationships: { resourceProperty: "hostnames" }, >+ relationshipI18n: { >+ nameUpperCase: __("Hostname"), >+ removeThisMessage: __("Remove this hostname"), >+ addNewMessage: __("Add hostname"), >+ noneCreatedYetMessage: __( >+ "No hostnames configured. Add a hostname to surface this provider on its login page." >+ ), >+ }, >+ newRelationshipDefaultAttrs: { >+ type: "object", >+ value: { >+ hostname: "", >+ force_sso: false, >+ }, >+ }, >+ }, >+ relationshipFields: [ >+ { >+ name: "hostname", >+ required: true, >+ indexRequired: true, >+ type: "select", >+ label: __("Hostname"), >+ placeholder: __("Select or type to add a new hostname..."), >+ options: [], >+ selectLabel: "hostname", >+ requiredKey: "hostname", >+ taggable: true, >+ createOption: h => ({ hostname: h }), >+ }, >+ { >+ name: "force_sso", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Force SSO"), >+ toolTip: __( >+ "Automatically redirect users on this hostname to this provider" >+ ), >+ badgeTrueLabel: __("Force SSO"), >+ badgeTrueClass: "bg-primary", >+ }, >+ ], >+ }; >+ >+ const mappingsAttr = { >+ name: "mappings", >+ type: "relationshipWidget", >+ label: __("Attribute Mappings"), >+ group: "Attribute Mappings", >+ hideIn: ["List"], >+ showElement: { >+ type: "table", >+ columnData: "mappings", >+ hidden: provider => !!provider.mappings?.length, >+ columns: [ >+ { name: __("IdP field"), value: "provider_field" }, >+ { name: __("Koha field"), value: "koha_field" }, >+ { name: __("Matchpoint"), value: "is_matchpoint" }, >+ { name: __("Default value"), value: "default_content" }, >+ ], >+ }, >+ componentProps: { >+ resourceRelationships: { resourceProperty: "mappings" }, >+ relationshipI18n: { >+ nameUpperCase: __("Mapping"), >+ noneCreatedYetMessage: __("No attribute mappings defined."), >+ addNewMessage: __("Add mapping"), >+ removeThisMessage: __("Remove"), >+ }, >+ newRelationshipDefaultAttrs: { >+ type: "object", >+ value: { >+ provider_field: "", >+ koha_field: borrowerColumnsArray[0]?.value || "", >+ default_content: "", >+ is_matchpoint: false, >+ }, >+ }, >+ }, >+ relationshipFields: [ >+ { >+ name: "provider_field", >+ type: "text", >+ label: __("IdP field"), >+ placeholder: __("e.g. given_name"), >+ }, >+ { >+ name: "koha_field", >+ required: true, >+ type: "select", >+ label: __("Koha field"), >+ options: borrowerColumnsArray, >+ requiredKey: "value", >+ selectLabel: "label", >+ }, >+ { >+ name: "is_matchpoint", >+ type: "checkbox", >+ label: __("Matchpoint"), >+ toolTip: __( >+ "Only one attribute per provider can be used as a matchpoint. Checking this will uncheck any other mapping that had it set." >+ ), >+ onChange: row => { >+ if ( >+ row.is_matchpoint && >+ activeResource.value?.mappings >+ ) { >+ activeResource.value.mappings.forEach(m => { >+ if (m !== row) m.is_matchpoint = false; >+ }); >+ } >+ }, >+ }, >+ { >+ name: "default_content", >+ type: "text", >+ label: __("Default value"), >+ }, >+ ], >+ }; >+ >+ const domainsAttr = { >+ name: "domains", >+ type: "relationshipWidget", >+ label: __("Email Domain Rules"), >+ group: "Email Domain Rules", >+ hideIn: ["List"], >+ showElement: { >+ type: "table", >+ columnData: "domains", >+ hidden: provider => !!provider.domains?.length, >+ columns: [ >+ { name: __("Domain"), value: "domain" }, >+ { name: __("Allow OPAC"), value: "allow_opac" }, >+ { name: __("Allow staff"), value: "allow_staff" }, >+ { >+ name: __("Auto-register (OPAC)"), >+ value: "auto_register_opac", >+ }, >+ { >+ name: __("Auto-register (staff)"), >+ value: "auto_register_staff", >+ }, >+ { name: __("Update on auth"), value: "update_on_auth" }, >+ { >+ name: __("Send welcome email"), >+ value: "send_welcome_email", >+ }, >+ { >+ name: __("Default library"), >+ value: "default_library_id", >+ }, >+ { >+ name: __("Default category"), >+ value: "default_category_id", >+ }, >+ ], >+ }, >+ componentProps: { >+ resourceRelationships: { resourceProperty: "domains" }, >+ relationshipI18n: { >+ nameUpperCase: __("Domain"), >+ removeThisMessage: __("Remove this domain"), >+ addNewMessage: __("Add domain rule"), >+ noneCreatedYetMessage: __("No domain rules defined."), >+ }, >+ newRelationshipDefaultAttrs: { >+ type: "object", >+ value: { >+ domain: "", >+ allow_opac: false, >+ allow_staff: false, >+ auto_register_opac: false, >+ auto_register_staff: false, >+ update_on_auth: false, >+ send_welcome_email: false, >+ default_library_id: librariesArray[0]?.value || "", >+ default_category_id: categoriesArray[0]?.value || "", >+ }, >+ }, >+ }, >+ relationshipFields: [ >+ { >+ name: "domain", >+ type: "text", >+ indexRequired: true, >+ label: __("Domain"), >+ placeholder: __("e.g. library.org or *"), >+ toolTip: __( >+ "Email domain to match. Use '*' or leave empty for any domain." >+ ), >+ }, >+ { >+ name: "allow_opac", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Allow OPAC login"), >+ badgeTrueLabel: __("OPAC"), >+ badgeTrueClass: "bg-success", >+ }, >+ { >+ name: "allow_staff", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Allow staff login"), >+ badgeTrueLabel: __("Staff"), >+ badgeTrueClass: "bg-primary", >+ }, >+ { >+ name: "auto_register_opac", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Auto-register (OPAC)"), >+ }, >+ { >+ name: "auto_register_staff", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Auto-register (staff)"), >+ }, >+ { >+ name: "update_on_auth", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Update patron data on login"), >+ }, >+ { >+ name: "send_welcome_email", >+ type: "boolean", >+ indexRequired: true, >+ label: __("Send welcome email"), >+ toolTip: __( >+ "Send a welcome email to the patron on their first login via this provider" >+ ), >+ }, >+ { >+ name: "default_library_id", >+ type: "select", >+ indexRequired: true, >+ label: __("Default library"), >+ options: librariesArray, >+ requiredKey: "value", >+ selectLabel: "label", >+ }, >+ { >+ name: "default_category_id", >+ type: "select", >+ indexRequired: true, >+ label: __("Default category"), >+ options: categoriesArray, >+ requiredKey: "value", >+ selectLabel: "label", >+ }, >+ ], >+ }; >+ >+ const resourceAttrs = [ >+ ...staticResourceAttrs, >+ protocolPlaceholderAttr, >+ ...configResourceAttrs, >+ hostnameAttr, >+ mappingsAttr, >+ domainsAttr, >+ ]; >+ >+ // Unpack the JSON config blob into flat _config_* fields so the form >+ // and show views can bind to them individually. >+ const afterResourceFetch = (componentData, resource) => { >+ selectedProtocol.value = resource.protocol || null; >+ const config = resource.config || {}; >+ const fields = PROTOCOL_CONFIG_FIELDS[resource.protocol] || []; >+ fields.forEach(field => { >+ if (field.type === "group_placeholder") return; >+ resource[`_config_${field.name}`] = >+ field.type === "boolean" >+ ? (config[field.name] ?? false) >+ : (config[field.name] ?? ""); >+ }); >+ if (!resource.domains) resource.domains = []; >+ if (!resource.mappings) resource.mappings = []; >+ resource.hostnames = resource.hostnames || []; >+ >+ // Capture the reactive resource so onChange closures (e.g. >+ // is_matchpoint) can reach sibling rows. >+ activeResource.value = resource; >+ >+ // Store the IDs of existing sub-resources so we can delete any >+ // that the user removes during an edit. >+ originalMappingIds.value = resource.mappings >+ .map(m => m.mapping_id) >+ .filter(Boolean); >+ originalHostnameIds.value = resource.hostnames >+ .map(h => h.identity_provider_hostname_id) >+ .filter(Boolean); >+ originalDomainIds.value = resource.domains >+ .map(d => d.identity_provider_domain_id) >+ .filter(Boolean); >+ }; >+ >+ const baseResource = useBaseResource({ >+ resourceName: "provider", >+ nameAttr: "code", >+ idAttr: "identity_provider_id", >+ components: { >+ show: "ProviderShow", >+ list: "ProvidersList", >+ add: "ProviderFormAdd", >+ edit: "ProviderFormEdit", >+ }, >+ apiClient: APIClient.identity_providers.providers, >+ i18n: { >+ deleteConfirmationMessage: $__( >+ "Are you sure you want to delete this identity provider? All related domain and mapping configuration will also be deleted." >+ ), >+ deleteSuccessMessage: $__("Identity provider deleted"), >+ displayName: $__("Identity provider"), >+ editLabel: $__("Edit identity provider"), >+ emptyListMessage: $__( >+ "There are no identity providers configured" >+ ), >+ newLabel: $__("New identity provider"), >+ }, >+ table: { >+ resourceTableUrl: "/api/v1/auth/identity_providers", >+ options: {}, >+ }, >+ stickyToolbar: ["Form"], >+ embedded: props.embedded, >+ formGroupsDisplayMode: "accordion", >+ resourceAttrs, >+ afterResourceFetch, >+ props, >+ moduleStore: "IdentityProvidersStore", >+ }); >+ >+ const tableOptions = { >+ url: baseResource.getResourceTableUrl(), >+ actions: { >+ "-1": ["delete"], >+ }, >+ }; >+ >+ // In add mode the form uses reactive(newResource) as its data object. >+ // reactive() uses a WeakMap so calling it with the same plain object >+ // returns the same proxy. Watching here therefore sees the same >+ // mutations that ResourceFormSave makes when the user changes the >+ // protocol dropdown. >+ const formStateObj = reactive(baseResource.newResource.value); >+ watch( >+ () => formStateObj.protocol, >+ newProtocol => { >+ selectedProtocol.value = newProtocol || null; >+ } >+ ); >+ >+ onMounted(async () => { >+ try { >+ const data = >+ await APIClient.identity_providers.allHostnames.getAll(); >+ hostnameAttr.relationshipFields[0].options = data || []; >+ } catch (_) { >+ // proceed with empty options; user can still type a hostname >+ } >+ // In add mode afterResourceFetch is never called, so point >+ // activeResource at the new-resource form state object instead. >+ if (!activeResource.value) { >+ activeResource.value = formStateObj; >+ } >+ initialized.value = true; >+ }); >+ >+ const onFormSave = async (e, providerToSave) => { >+ e.preventDefault(); >+ const provider = JSON.parse(JSON.stringify(providerToSave)); >+ const providerId = provider.identity_provider_id; >+ const protocol = provider.protocol; >+ >+ // Collect the _config_* fields for the selected protocol into a >+ // config object, then strip all _config_* keys from the payload. >+ const configFieldDefs = PROTOCOL_CONFIG_FIELDS[protocol] || []; >+ const config = {}; >+ configFieldDefs.forEach(field => { >+ if (field.type === "group_placeholder") return; >+ const flatKey = `_config_${field.name}`; >+ if (flatKey in provider) { >+ config[field.name] = provider[flatKey]; >+ } >+ }); >+ // Strip all _-prefixed keys (UI-only fields: _config_*, _protocol_*, etc.) >+ Object.keys(provider) >+ .filter(k => k.startsWith("_")) >+ .forEach(k => delete provider[k]); >+ provider.config = config; >+ >+ // Hostnames are managed separately via the hostnames API. >+ const hostnamesFromForm = (provider.hostnames || []).filter( >+ h => h.hostname >+ ); >+ delete provider.hostnames; >+ >+ // Mappings are managed separately via the mappings API. >+ // Only items with a koha_field are saved. >+ const mappingsFromForm = (provider.mappings || []).filter( >+ m => m.koha_field >+ ); >+ delete provider.mappings; >+ >+ // Domains are managed separately via the domains API. >+ const domainsFromForm = provider.domains || []; >+ delete provider.domains; >+ >+ delete provider.identity_provider_id; >+ >+ try { >+ if (providerId) { >+ const updatedProvider = await baseResource.apiClient.update( >+ provider, >+ providerId >+ ); >+ >+ // Sync hostnames: delete removed, update existing, create new. >+ const keptHostnameIds = hostnamesFromForm >+ .map(h => h.identity_provider_hostname_id) >+ .filter(Boolean); >+ for (const id of originalHostnameIds.value.filter( >+ id => !keptHostnameIds.includes(id) >+ )) { >+ await APIClient.identity_providers.hostnames.delete( >+ providerId, >+ id >+ ); >+ } >+ for (const h of hostnamesFromForm) { >+ const body = { >+ hostname: h.hostname, >+ is_enabled: h.is_enabled ?? true, >+ force_sso: h.force_sso ?? false, >+ }; >+ if (h.identity_provider_hostname_id) { >+ await APIClient.identity_providers.hostnames.update( >+ providerId, >+ body, >+ h.identity_provider_hostname_id >+ ); >+ } else { >+ await APIClient.identity_providers.hostnames.create( >+ providerId, >+ body >+ ); >+ } >+ } >+ >+ // Sync mappings: delete removed, update existing, create new. >+ const keptMappingIds = mappingsFromForm >+ .map(m => m.mapping_id) >+ .filter(Boolean); >+ for (const id of originalMappingIds.value.filter( >+ id => !keptMappingIds.includes(id) >+ )) { >+ await APIClient.identity_providers.mappings.delete( >+ providerId, >+ id >+ ); >+ } >+ for (const m of mappingsFromForm) { >+ const body = { >+ provider_field: m.provider_field || null, >+ koha_field: m.koha_field, >+ default_content: m.default_content || null, >+ is_matchpoint: m.is_matchpoint || false, >+ }; >+ if (m.mapping_id) { >+ await APIClient.identity_providers.mappings.update( >+ providerId, >+ body, >+ m.mapping_id >+ ); >+ } else { >+ await APIClient.identity_providers.mappings.create( >+ providerId, >+ body >+ ); >+ } >+ } >+ >+ // Sync domains: delete removed, update existing, create new. >+ const keptDomainIds = domainsFromForm >+ .map(d => d.identity_provider_domain_id) >+ .filter(Boolean); >+ for (const id of originalDomainIds.value.filter( >+ id => !keptDomainIds.includes(id) >+ )) { >+ await APIClient.identity_providers.domains.delete( >+ providerId, >+ id >+ ); >+ } >+ for (const d of domainsFromForm) { >+ const body = { >+ domain: d.domain || null, >+ allow_opac: d.allow_opac || false, >+ allow_staff: d.allow_staff || false, >+ auto_register_opac: d.auto_register_opac || false, >+ auto_register_staff: d.auto_register_staff || false, >+ update_on_auth: d.update_on_auth || false, >+ send_welcome_email: d.send_welcome_email || false, >+ default_library_id: d.default_library_id || null, >+ default_category_id: d.default_category_id || null, >+ }; >+ if (d.identity_provider_domain_id) { >+ await APIClient.identity_providers.domains.update( >+ providerId, >+ body, >+ d.identity_provider_domain_id >+ ); >+ } else { >+ await APIClient.identity_providers.domains.create( >+ providerId, >+ body >+ ); >+ } >+ } >+ >+ baseResource.setMessage($__("Identity provider updated")); >+ return updatedProvider; >+ } else { >+ const newProvider = >+ await baseResource.apiClient.create(provider); >+ const newId = newProvider.identity_provider_id; >+ >+ // Create a bridge record for each linked hostname >+ for (const h of hostnamesFromForm) { >+ await APIClient.identity_providers.hostnames.create( >+ newId, >+ { >+ hostname: h.hostname, >+ is_enabled: true, >+ force_sso: h.force_sso ?? false, >+ } >+ ); >+ } >+ >+ // Create each attribute mapping >+ for (const m of mappingsFromForm) { >+ await APIClient.identity_providers.mappings.create( >+ newId, >+ { >+ provider_field: m.provider_field || null, >+ koha_field: m.koha_field, >+ default_content: m.default_content || null, >+ is_matchpoint: m.is_matchpoint || false, >+ } >+ ); >+ } >+ >+ // Create each domain rule >+ for (const d of domainsFromForm) { >+ await APIClient.identity_providers.domains.create( >+ newId, >+ { >+ domain: d.domain || null, >+ allow_opac: d.allow_opac || false, >+ allow_staff: d.allow_staff || false, >+ auto_register_opac: >+ d.auto_register_opac || false, >+ auto_register_staff: >+ d.auto_register_staff || false, >+ update_on_auth: d.update_on_auth || false, >+ send_welcome_email: >+ d.send_welcome_email || false, >+ default_library_id: >+ d.default_library_id || null, >+ default_category_id: >+ d.default_category_id || null, >+ } >+ ); >+ } >+ >+ baseResource.setMessage($__("Identity provider created")); >+ return newProvider; >+ } >+ } catch (error) { >+ // errors surfaced by the httpClient >+ } >+ }; >+ >+ return { >+ ...baseResource, >+ initialized, >+ PROTOCOL_CONFIG_FIELDS, >+ tableOptions, >+ onFormSave, >+ }; >+ }, >+}; >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Islands/AdminMenu.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Islands/AdminMenu.vue >index bc806611f19..5f769af62e4 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Islands/AdminMenu.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Islands/AdminMenu.vue >@@ -120,18 +120,6 @@ > >{{ $__("Self-service circulation (SIP2)") }}</a > > > </li> >- <li >- v-if=" >- can_user_parameters_manage_identity_providers && >- shibbolethauthentication >- " >- > >- <a >- :ref="el => templateRefs.push(el)" >- href="/cgi-bin/koha/shibboleth/shibboleth.pl" >- >{{ $__("Shibboleth configuration") }}</a >- > >- </li> > <li v-if="can_user_parameters_manage_item_circ_alerts"> > <a > :ref="el => templateRefs.push(el)" >@@ -601,7 +589,6 @@ export default { > can_user_parameters_manage_additional_fields: Number, > can_user_parameters_manage_keyboard_shortcuts: Number, > can_user_ill: Number, >- shibbolethauthentication: Number, > usecirculationdesks: Number, > usecashregisters: Number, > savedsearchfilters: Number, >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >index 2906a9c296b..e45f610cdff 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js >@@ -10,6 +10,7 @@ import ItemAPIClient from "@fetch/item-api-client"; > import RecordSourcesAPIClient from "@fetch/record-sources-api-client"; > import SysprefAPIClient from "@fetch/system-preferences-api-client"; > import SIP2APIClient from "@fetch/sip2-api-client"; >+import IdentityProvidersAPIClient from "@fetch/identity-providers-api-client"; > import PreservationAPIClient from "@fetch/preservation-api-client"; > > export const APIClient = { >@@ -22,6 +23,7 @@ export const APIClient = { > item: new ItemAPIClient(HttpClient), > sysprefs: new SysprefAPIClient(HttpClient), > sip2: new SIP2APIClient(HttpClient), >+ identity_providers: new IdentityProvidersAPIClient(HttpClient), > preservation: new PreservationAPIClient(HttpClient), > record_sources: new RecordSourcesAPIClient(HttpClient), > }; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/identity-providers.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/identity-providers.ts >new file mode 100644 >index 00000000000..efd055862cb >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/identity-providers.ts >@@ -0,0 +1,76 @@ >+import { createApp } from "vue"; >+import { createWebHistory, createRouter } from "vue-router"; >+import { createPinia } from "pinia"; >+ >+import { library } from "@fortawesome/fontawesome-svg-core"; >+import { >+ faPlus, >+ faMinus, >+ faPencil, >+ faTrash, >+ faSpinner, >+ faSave, >+ faCog, >+ faExchangeAlt, >+ faIdCard, >+ faGlobe, >+} from "@fortawesome/free-solid-svg-icons"; >+import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; >+import vSelect from "vue-select"; >+ >+library.add( >+ faPlus, >+ faMinus, >+ faPencil, >+ faTrash, >+ faSpinner, >+ faSave, >+ faCog, >+ faExchangeAlt, >+ faIdCard, >+ faGlobe >+); >+ >+import App from "../components/IdentityProviders/Main.vue"; >+ >+import { routes as routesDef } from "../routes/identity-providers"; >+ >+import { useMainStore } from "../stores/main"; >+import { useIdentityProvidersStore } from "../stores/identity-providers"; >+import { useNavigationStore } from "../stores/navigation"; >+import i18n from "../i18n"; >+ >+const pinia = createPinia(); >+ >+const mainStore = useMainStore(pinia); >+const navigationStore = useNavigationStore(pinia); >+const routes = navigationStore.setRoutes(routesDef); >+ >+const router = createRouter({ >+ history: createWebHistory(), >+ linkActiveClass: "current", >+ routes, >+}); >+ >+const app = createApp(App); >+ >+const rootComponent = app >+ .use(i18n) >+ .use(pinia) >+ .use(router) >+ .component("font-awesome-icon", FontAwesomeIcon) >+ .component("v-select", vSelect); >+ >+app.config.unwrapInjectedRef = true; >+app.provide("mainStore", mainStore); >+app.provide("navigationStore", navigationStore); >+const IdentityProvidersStore = useIdentityProvidersStore(pinia); >+app.provide("IdentityProvidersStore", IdentityProvidersStore); >+ >+app.mount("#identity-providers"); >+ >+const { removeMessages } = mainStore; >+router.beforeEach((to, from) => { >+ navigationStore.$patch({ current: to.matched, params: to.params || {} }); >+ removeMessages(); >+}); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/routes/identity-providers.js b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/identity-providers.js >new file mode 100644 >index 00000000000..62f565660fa >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/identity-providers.js >@@ -0,0 +1,125 @@ >+import { markRaw } from "vue"; >+ >+import ResourceWrapper from "../components/ResourceWrapper.vue"; >+import { $__ } from "../i18n"; >+ >+export const routes = [ >+ { >+ path: "/cgi-bin/koha/admin/admin-home.pl", >+ title: $__("Administration"), >+ children: [ >+ { >+ path: "/cgi-bin/koha/admin/identity_providers.pl", >+ is_default: true, >+ is_base: true, >+ title: $__("Identity providers"), >+ children: [ >+ { >+ path: "", >+ redirect: { name: "ProvidersList" }, >+ }, >+ { >+ path: "/cgi-bin/koha/admin/identity_providers", >+ is_empty: true, >+ is_end_node: true, >+ resource: "IdentityProviders/ProviderResource.vue", >+ children: [ >+ { >+ path: "", >+ name: "ProvidersList", >+ is_empty: true, >+ component: markRaw(ResourceWrapper), >+ }, >+ { >+ path: ":identity_provider_id", >+ name: "ProviderShow", >+ component: markRaw(ResourceWrapper), >+ title: $__("Show provider"), >+ }, >+ { >+ path: "add", >+ name: "ProviderFormAdd", >+ component: markRaw(ResourceWrapper), >+ title: $__("New identity provider"), >+ }, >+ { >+ path: "edit/:identity_provider_id", >+ name: "ProviderFormEdit", >+ component: markRaw(ResourceWrapper), >+ title: $__("Edit identity provider"), >+ }, >+ { >+ path: ":identity_provider_id/mappings", >+ title: $__("Field mappings"), >+ icon: "fa fa-exchange-alt", >+ is_end_node: true, >+ resource: >+ "IdentityProviders/MappingResource.vue", >+ children: [ >+ { >+ path: "", >+ name: "MappingsList", >+ is_empty: true, >+ component: markRaw(ResourceWrapper), >+ }, >+ { >+ path: ":identity_provider_mapping_id", >+ name: "MappingShow", >+ component: markRaw(ResourceWrapper), >+ title: $__("Show mapping"), >+ }, >+ { >+ path: "add", >+ name: "MappingsFormAdd", >+ component: markRaw(ResourceWrapper), >+ title: $__("New field mapping"), >+ }, >+ { >+ path: "edit/:identity_provider_mapping_id", >+ name: "MappingsFormEdit", >+ component: markRaw(ResourceWrapper), >+ title: $__("Edit field mapping"), >+ }, >+ ], >+ }, >+ { >+ path: ":identity_provider_id/domains", >+ title: $__("Domains"), >+ icon: "fa fa-globe", >+ is_end_node: true, >+ resource: >+ "IdentityProviders/DomainResource.vue", >+ children: [ >+ { >+ path: "", >+ name: "DomainsList", >+ is_empty: true, >+ component: markRaw(ResourceWrapper), >+ }, >+ { >+ path: ":identity_provider_domain_id", >+ name: "DomainShow", >+ component: markRaw(ResourceWrapper), >+ title: $__("Show domain"), >+ }, >+ { >+ path: "add", >+ name: "DomainsFormAdd", >+ component: markRaw(ResourceWrapper), >+ title: $__("New domain"), >+ }, >+ { >+ path: "edit/:identity_provider_domain_id", >+ name: "DomainsFormEdit", >+ component: markRaw(ResourceWrapper), >+ title: $__("Edit domain"), >+ }, >+ ], >+ }, >+ ], >+ }, >+ ], >+ }, >+ ], >+ }, >+]; >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/identity-providers.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/identity-providers.js >new file mode 100644 >index 00000000000..52521e6586b >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/identity-providers.js >@@ -0,0 +1,15 @@ >+import { defineStore } from "pinia"; >+ >+export const useIdentityProvidersStore = defineStore("identity_providers", { >+ state: () => ({ >+ current_provider: null, >+ }), >+ actions: { >+ setCurrentProvider(provider) { >+ this.current_provider = provider; >+ }, >+ clearCurrentProvider() { >+ this.current_provider = null; >+ }, >+ }, >+}); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/navigation.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/navigation.js >index 887f0fd0df1..589ff696e24 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/navigation.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/navigation.js >@@ -190,6 +190,9 @@ export const useNavigationStore = defineStore("navigation", () => { > } > }), > leftNavigation: computed(() => { >+ if (!store.current || store.current.length === 0) { >+ return _getNavigationElements(store.routeState); >+ } > const currentRoute = store.current[store.current.length - 1]; > if (currentRoute) { > const alternateMenuRequired = >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >index 2b6d4478d6d..bb02cd28d30 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/masthead.inc >@@ -136,7 +136,7 @@ > <a tabindex="0" role="menuitem" class="logout js-hide" href="/cgi-bin/koha/opac-main.pl?logout.x=1"><i class="fa fa-fw fa-sign-out" aria-hidden="true"></i> Log out</a> > [% ELSE %] > [% IF Koha.Preference('casAuthentication') %] >- [%# CAS authentication is too complicated for modal window %] >+ [%# CAS authentication and Shibboleth force SSO are too complicated for modal window %] > <a class="nav-link login-link" href="/cgi-bin/koha/opac-user.pl" aria-label="Log in to your account" > ><i class="fa fa-user fa-icon-black fa-fw" aria-hidden="true"></i> <span class="userlabel">Log in to your account</span></a > > >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >index 6d6c135cc1a..db140ccbcec 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-auth.tt >@@ -97,110 +97,104 @@ > <!-- This is what is displayed if shibboleth login has failed to match a koha user --> > <div class="alert alert-info"> > <p aria-live="assertive" role="alert" class="shib_invalid">Sorry, your Shibboleth identity does not match a valid library identity.</p> >- [% UNLESS ( Koha.Preference('OPACShibOnly') ) %] >- [% IF ( casAuthentication ) %] >- [% IF ( invalidCasLogin ) %] >- <!-- This is what is displayed if cas login has failed --> >- <p class="cas_invalid">Sorry, the CAS login also failed. If you have a local login you may use that below.</p> >- [% ELSE %] >- <p>If you have a CAS account, you may use that below.</p> >- [% END %] >+ [% IF ( casAuthentication ) %] >+ [% IF ( invalidCasLogin ) %] >+ <!-- This is what is displayed if cas login has failed --> >+ <p class="cas_invalid">Sorry, the CAS login also failed. If you have a local login you may use that below.</p> > [% ELSE %] >- <p>If you have a local account, you may use that below.</p> >+ <p>If you have a CAS account, you may use that below.</p> > [% END %] >+ [% ELSE %] >+ <p>If you have a local account, you may use that below.</p> > [% END %] > </div> > [% ELSE %] > <h2 class="shib_title">Shibboleth login</h2> > <p><a class="shib_url" href="[% shibbolethLoginUrl | $raw %]">Log in using a Shibboleth account.</a></p> > [% END # /IF invalidShibLogin %] >- [% UNLESS ( Koha.Preference('OPACShibOnly') ) %] >- [% IF ( casAuthentication ) %] >- <h2 class="cas_title">CAS login</h2> >- <p>If you do not have a Shibboleth account, but you do have a CAS account, you can use CAS.</p> >- [% ELSE %] >- <h2 class="shib_local_title">Local login</h2> >- <p class="shib_local_text">If you do not have a Shibboleth account, but you do have a local login, then you may login below.</p> >- [% END %] >+ [% IF ( casAuthentication ) %] >+ <h2 class="cas_title">CAS login</h2> >+ <p>If you do not have a Shibboleth account, but you do have a CAS account, you can use CAS.</p> >+ [% ELSE %] >+ <h2 class="shib_local_title">Local login</h2> >+ <p class="shib_local_text">If you do not have a Shibboleth account, but you do have a local login, then you may login below.</p> > [% END %] > [% END # /IF shibbolethAuthentication %] > >- [% UNLESS ( Koha.Preference('OPACShibOnly') ) %] >- [% IF ( casAuthentication ) %] >- [% IF ( shibbolethAuthentication ) %] >- [% IF ( casServerUrl ) %] >- <p><a class="cas_url" href="[% casServerUrl | $raw %]">Log in.</a></p> >- [% END %] >- >- [% IF ( casServersLoop ) %] >- <p>Please choose against which one you would like to authenticate: </p> >- <ul> >- [% FOREACH casServer IN casServersLoop %] >- <li><a class="cas_url" href="[% casServer.value | $raw %]">[% casServer.name | html %]</a></li> >- [% END %] >- </ul> >- [% END %] >- [% ELSE %] >- [% IF ( invalidCasLogin ) %] >- <div class="alert alert-info"> >- <!-- This is what is displayed if cas login has failed --> >- <p class="cas_invalid" role="alert" aria-live="assertive">Sorry, the CAS login failed.</p> >- </div> >- [% END %] >- >- <h2 class="cas_title">CAS login</h2> >+ [% IF ( casAuthentication ) %] >+ [% IF ( shibbolethAuthentication ) %] >+ [% IF ( casServerUrl ) %] >+ <p><a class="cas_url" href="[% casServerUrl | $raw %]">Log in.</a></p> >+ [% END %] > >- [% IF ( casServerUrl ) %] >- <p><a class="cas_url" href="[% casServerUrl | $raw %]">Log in using a CAS account.</a></p> >- [% END %] >+ [% IF ( casServersLoop ) %] >+ <p>Please choose against which one you would like to authenticate: </p> >+ <ul> >+ [% FOREACH casServer IN casServersLoop %] >+ <li><a class="cas_url" href="[% casServer.value | $raw %]">[% casServer.name | html %]</a></li> >+ [% END %] >+ </ul> >+ [% END %] >+ [% ELSE %] >+ [% IF ( invalidCasLogin ) %] >+ <div class="alert alert-info"> >+ <!-- This is what is displayed if cas login has failed --> >+ <p class="cas_invalid" role="alert" aria-live="assertive">Sorry, the CAS login failed.</p> >+ </div> >+ [% END %] > >- [% IF ( casServersLoop ) %] >- <p>If you have a CAS account, please choose against which one you would like to authenticate:</p> >- <ul> >- [% FOREACH casServer IN casServersLoop %] >- <li><a class="cas_url" href="[% casServer.value | $raw %]">[% casServer.name | html %]</a></li> >- [% END %] >- </ul> >- [% END %] >- [% END # /IF shibbolethAuthentication %] >+ <h2 class="cas_title">CAS login</h2> > >- [% IF ( shibbolethAuthentication ) %] >- <p>Nothing</p> >- [% ELSE %] >- <h2>Local login</h2> >- <p>If you do not have a CAS account, but do have a local account, you can still log in: </p> >+ [% IF ( casServerUrl ) %] >+ <p><a class="cas_url" href="[% casServerUrl | $raw %]">Log in using a CAS account.</a></p> > [% END %] >- [% END # / IF casAuthentication %] > >- [% SET identity_providers = AuthClient.get_providers('opac') %] >- [% IF ( ! identity_providers.empty ) %] >- [% FOREACH provider IN identity_providers %] >- <p class="clearfix"> >- <a href="[% provider.url | url %]" class="btn btn-light col-md-12" id="provider_[% provider.code | html %]"> >- [% IF provider.icon_url %] >- <img src="[% provider.icon_url | url %]" style="max-height: 20px; max-width: 20px;" /> >- [% ELSE %] >- <i class="fa fa-user" aria-hidden="true"></i> >- [% END %] >- Log in with [% provider.description | html %] >- </a> >- </p> >+ [% IF ( casServersLoop ) %] >+ <p>If you have a CAS account, please choose against which one you would like to authenticate:</p> >+ <ul> >+ [% FOREACH casServer IN casServersLoop %] >+ <li><a class="cas_url" href="[% casServer.value | $raw %]">[% casServer.name | html %]</a></li> >+ [% END %] >+ </ul> > [% END %] >- <hr /> >- <p>If you do not have an external account, but do have a local account, you can still log in: </p> >- [% END # /IF identity_providers %] >+ [% END # /IF shibbolethAuthentication %] > >- [% IF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %] >- [% IF ( invalidGoogleOpenIDConnectLogin ) %] >- <h2>Google login</h2> >- <p>Sorry, your Google login failed. <span class="error">[% invalidGoogleOpenIDConnectLogin | html %]</span></p> >- <p>Please note that the Google login will only work if you are using the e-mail address registered with this library.</p> >- <p>If you want to, you can try to <a href="/cgi-bin/koha/svc/auth/googleopenidconnect?reauthenticate=select_account">log in using a different account</a> </p> >- [% END %] >- <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" class="btn btn-light" id="openid_connect"><i class="fa-brands fa-google" aria-hidden="true"></i> Log in with Google</a> >- <p>If you do not have a Google account, but do have a local account, you can still log in: </p> >- [% END # /IF GoogleOpenIDConnect %] >- [% END # /UNLESS OPACShibOnly %] >+ [% IF ( shibbolethAuthentication ) %] >+ <p>Nothing</p> >+ [% ELSE %] >+ <h2>Local login</h2> >+ <p>If you do not have a CAS account, but do have a local account, you can still log in: </p> >+ [% END %] >+ [% END # / IF casAuthentication %] >+ >+ [% SET identity_providers = AuthClient.get_providers('opac') %] >+ [% IF ( ! identity_providers.empty ) %] >+ [% FOREACH provider IN identity_providers %] >+ <p class="clearfix"> >+ <a href="[% provider.url | url %]" class="btn btn-light col-md-12" id="provider_[% provider.code | html %]"> >+ [% IF provider.icon_url %] >+ <img src="[% provider.icon_url | url %]" style="max-height: 20px; max-width: 20px;" /> >+ [% ELSE %] >+ <i class="fa fa-user" aria-hidden="true"></i> >+ [% END %] >+ Log in with [% provider.description | html %] >+ </a> >+ </p> >+ [% END %] >+ <hr /> >+ <p>If you do not have an external account, but do have a local account, you can still log in: </p> >+ [% END # /IF identity_providers %] >+ >+ [% IF ( Koha.Preference('GoogleOpenIDConnect') == 1 ) %] >+ [% IF ( invalidGoogleOpenIDConnectLogin ) %] >+ <h2>Google login</h2> >+ <p>Sorry, your Google login failed. <span class="error">[% invalidGoogleOpenIDConnectLogin | html %]</span></p> >+ <p>Please note that the Google login will only work if you are using the e-mail address registered with this library.</p> >+ <p>If you want to, you can try to <a href="/cgi-bin/koha/svc/auth/googleopenidconnect?reauthenticate=select_account">log in using a different account</a> </p> >+ [% END %] >+ <a href="/cgi-bin/koha/svc/auth/googleopenidconnect" class="btn btn-light" id="openid_connect"><i class="fa-brands fa-google" aria-hidden="true"></i> Log in with Google</a> >+ <p>If you do not have a Google account, but do have a local account, you can still log in: </p> >+ [% END # /IF GoogleOpenIDConnect %] > > [% IF !(invalid_username_or_password || too_many_login_attempts) and is_anonymous_patron %] > <div class="alert alert-info"> >@@ -227,7 +221,7 @@ > [% ELSE %] > <p>You must contact the library to reset your password</p> > [% END %] >- [% ELSIF !Koha.Preference('OPACShibOnly') or SCO_login or SCI_login %] >+ [% ELSIF SCO_login or SCI_login %] > [% SET form_action = script_name %] > [% IF SCO_login %] > [% form_action = "/cgi-bin/koha/sco/sco-main.pl" %] >diff --git a/opac/opac-user.pl b/opac/opac-user.pl >index e0671b33aa5..be4f6374096 100755 >--- a/opac/opac-user.pl >+++ b/opac/opac-user.pl >@@ -21,8 +21,9 @@ use Modern::Perl; > use CGI qw ( -utf8 ); > use URI; > >-use C4::Auth qw( get_template_and_user ); >-use C4::Koha qw( >+use C4::Auth qw( get_template_and_user ); >+use C4::Auth_with_shibboleth qw( shib_ok ); >+use C4::Koha qw( > getitemtypeimagelocation > GetNormalizedISBN > GetNormalizedUPC >@@ -82,7 +83,7 @@ for ( C4::Context->preference("OPACShowHoldQueueDetails") ) { > my $patronupdate = $query->param('patronupdate'); > my $canrenew = 1; > >-$template->param( shibbolethAuthentication => C4::Context->config('useshibboleth') ); >+$template->param( shibbolethAuthentication => shib_ok() ); > > # get borrower information .... > my $patron = Koha::Patrons->find($borrowernumber); >diff --git a/rspack.config.js b/rspack.config.js >index 62cf6b1956c..ad2ab7e3335 100644 >--- a/rspack.config.js >+++ b/rspack.config.js >@@ -28,6 +28,8 @@ module.exports = [ > "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/acquisitions.ts", > islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts", > sip2: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/sip2.ts", >+ "admin/identity_providers": >+ "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/identity-providers.ts", > }, > output: { > filename: "[name].js", >-- >2.53.0 >
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 39224
:
189234
|
189235
|
189236
|
189237
|
189238
|
189239
|
189240
|
189241
|
189242
|
189243
|
189244
|
193618
|
193619
|
193620
|
193621
|
193622
|
193623
|
193624
|
193625
|
193626
|
193627
|
193681
|
193682
|
193683
|
193684
|
193685
|
193686
|
193687
|
193688
|
193689
|
193690
|
193691
|
193692