From 8fb03558085c45ef878464561cb12134e5f53f72 Mon Sep 17 00:00:00 2001
From: Juraj Roka <95219754+jr-rk@users.noreply.github.com>
Date: Wed, 11 Mar 2026 16:14:09 +0100
Subject: [PATCH 1/3] feat: add CLARIN WAYF IdP picker component with fuzzy
search, login page toggle, and header dropdown tab integration
---
src/app/app-routes.ts | 5 +
src/app/clarin-wayf/clarin-wayf-routes.ts | 14 +
src/app/clarin-wayf/clarin-wayf.component.ts | 263 ++++++++++++++++++
.../idp-card/wayf-idp-card.component.ts | 127 +++++++++
.../idp-list/wayf-idp-list.component.ts | 115 ++++++++
.../recent-idps/wayf-recent-idps.component.ts | 123 ++++++++
.../search-bar/wayf-search-bar.component.ts | 84 ++++++
src/app/clarin-wayf/models/idp-entry.model.ts | 30 ++
.../clarin-wayf/models/wayf-config.model.ts | 35 +++
src/app/clarin-wayf/services/feed.service.ts | 58 ++++
src/app/clarin-wayf/services/i18n.service.ts | 104 +++++++
.../services/persistence.service.ts | 76 +++++
.../services/search.service.spec.ts | 242 ++++++++++++++++
.../clarin-wayf/services/search.service.ts | 186 +++++++++++++
src/app/login-page/login-page.component.html | 31 +++
src/app/login-page/login-page.component.ts | 27 +-
.../auth-nav-menu.component.html | 44 ++-
.../auth-nav-menu.component.scss | 19 +-
.../auth-nav-menu/auth-nav-menu.component.ts | 20 ++
.../log-in/methods/auth-methods.type.ts | 4 +-
.../methods/log-in.methods-decorator.ts | 3 +-
.../log-in-shibboleth-wayf.component.ts | 166 +++++++++++
src/assets/i18n/en.json5 | 14 +
src/assets/mock/wayf-feed.json | 133 +++++++++
.../app/login-page/login-page.component.ts | 2 +
.../auth-nav-menu/auth-nav-menu.component.ts | 2 +
26 files changed, 1921 insertions(+), 6 deletions(-)
create mode 100644 src/app/clarin-wayf/clarin-wayf-routes.ts
create mode 100644 src/app/clarin-wayf/clarin-wayf.component.ts
create mode 100644 src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.ts
create mode 100644 src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.ts
create mode 100644 src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.ts
create mode 100644 src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.ts
create mode 100644 src/app/clarin-wayf/models/idp-entry.model.ts
create mode 100644 src/app/clarin-wayf/models/wayf-config.model.ts
create mode 100644 src/app/clarin-wayf/services/feed.service.ts
create mode 100644 src/app/clarin-wayf/services/i18n.service.ts
create mode 100644 src/app/clarin-wayf/services/persistence.service.ts
create mode 100644 src/app/clarin-wayf/services/search.service.spec.ts
create mode 100644 src/app/clarin-wayf/services/search.service.ts
create mode 100644 src/app/shared/log-in/methods/shibboleth-wayf/log-in-shibboleth-wayf.component.ts
create mode 100644 src/assets/mock/wayf-feed.json
diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts
index c967418daeb..f3916031aa3 100644
--- a/src/app/app-routes.ts
+++ b/src/app/app-routes.ts
@@ -183,6 +183,11 @@ export const APP_ROUTES: Route[] = [
.then((m) => m.ROUTES),
canActivate: [notAuthenticatedGuard],
},
+ {
+ path: 'wayf',
+ loadChildren: () => import('./clarin-wayf/clarin-wayf-routes')
+ .then((m) => m.ROUTES),
+ },
{
path: 'logout',
loadChildren: () => import('./logout-page/logout-page-routes')
diff --git a/src/app/clarin-wayf/clarin-wayf-routes.ts b/src/app/clarin-wayf/clarin-wayf-routes.ts
new file mode 100644
index 00000000000..ba2e920d160
--- /dev/null
+++ b/src/app/clarin-wayf/clarin-wayf-routes.ts
@@ -0,0 +1,14 @@
+import { Route } from '@angular/router';
+
+import { ClarinWayfComponent } from './clarin-wayf.component';
+import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
+
+export const ROUTES: Route[] = [
+ {
+ path: '',
+ pathMatch: 'full',
+ component: ClarinWayfComponent,
+ resolve: { breadcrumb: i18nBreadcrumbResolver },
+ data: { breadcrumbKey: 'wayf', title: 'wayf.title' },
+ },
+];
diff --git a/src/app/clarin-wayf/clarin-wayf.component.ts b/src/app/clarin-wayf/clarin-wayf.component.ts
new file mode 100644
index 00000000000..1104a1a7cdc
--- /dev/null
+++ b/src/app/clarin-wayf/clarin-wayf.component.ts
@@ -0,0 +1,263 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ OnInit,
+ output,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import { IdpEntry } from './models/idp-entry.model';
+import { SamldsParams } from './models/wayf-config.model';
+import { WayfFeedService } from './services/feed.service';
+import { WayfI18nService } from './services/i18n.service';
+import { WayfPersistenceService } from './services/persistence.service';
+import { WayfSearchService } from './services/search.service';
+import { WayfSearchBarComponent } from './components/search-bar/wayf-search-bar.component';
+import { WayfIdpListComponent } from './components/idp-list/wayf-idp-list.component';
+import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-idps.component';
+
+/**
+ * Main CLARIN WAYF (Where Are You From) component.
+ *
+ * Implements the SAML Discovery Service protocol:
+ * - Reads entityID, return, returnIDParam, isPassive from query params
+ * - Lets the user search and select an IdP
+ * - Redirects to: {return}?{returnIDParam}={selectedIdP.entityID}
+ *
+ * Can also be used standalone (without SAMLDS params) as an IdP picker
+ * that emits the selected IdP.
+ */
+@Component({
+ selector: 'ds-clarin-wayf',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ WayfSearchBarComponent,
+ WayfIdpListComponent,
+ WayfRecentIdpsComponent,
+ ],
+ template: `
+
+
{{ i18n.t('wayf.title') }}
+
+ @if (feedService.loading()) {
+
+
+ {{ i18n.t('wayf.loading') }}
+
+
{{ i18n.t('wayf.loading') }}
+
+ }
+
+ @if (feedService.error()) {
+
+ {{ i18n.t('wayf.error.feed') }}
+
+ }
+
+ @if (!feedService.loading() && !feedService.error()) {
+
+
+
+
+
0"
+ (queryChange)="onQueryChange($event)"
+ (arrowDown)="onArrowDown()"
+ (escaped)="onEscaped()"
+ />
+
+
+ @if (searchQuery().length > 0) {
+
+ {{ i18n.t('wayf.search.results', { count: filteredEntries().length }) }}
+
+ }
+
+
+
+ }
+
+ `,
+ styles: [`
+ .wayf-container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 1rem;
+ }
+ .wayf-container__title {
+ text-align: center;
+ }
+ `],
+})
+export class ClarinWayfComponent implements OnInit {
+ protected readonly i18n = inject(WayfI18nService);
+ protected readonly feedService = inject(WayfFeedService);
+ protected readonly persistence = inject(WayfPersistenceService);
+ private readonly searchService = inject(WayfSearchService);
+ private readonly route = inject(ActivatedRoute);
+
+ // --- Inputs (configurable via route data or parent binding) ---
+
+ /** URL to the IdP JSON feed. */
+ readonly feedUrl = input('');
+
+ /** Tag to filter IdPs by. */
+ readonly categoryFilter = input(null);
+
+ /** JSON-stringified array of proxy/hub entityIDs. */
+ readonly proxyEntities = input('[]');
+
+ /** Language override. */
+ readonly lang = input('');
+
+ /** Emits the selected IdP entry (for embedded/overlay usage). */
+ readonly idpSelected = output();
+
+ // --- Internal state ---
+
+ readonly searchQuery = signal('');
+
+ /** SAMLDS params parsed from the URL. */
+ readonly samldsParams = signal({
+ entityID: null,
+ return: null,
+ returnIDParam: 'entityID',
+ isPassive: false,
+ });
+
+ /** Parsed set of proxy entity IDs. */
+ readonly hubEntityIdSet = computed(() => {
+ try {
+ const parsed: unknown = JSON.parse(this.proxyEntities());
+ if (Array.isArray(parsed)) {
+ return new Set(parsed.filter((item): item is string => typeof item === 'string'));
+ }
+ } catch {
+ // Invalid JSON
+ }
+ return new Set();
+ });
+
+ /** Entries filtered by search query. */
+ readonly filteredEntries = computed(() =>
+ this.searchService.filterEntries(
+ this.feedService.entries(),
+ this.searchQuery(),
+ this.i18n.lang(),
+ ),
+ );
+
+ /** Final display order: hub entries pinned first, then filtered results. */
+ readonly displayEntries = computed(() => {
+ const filtered = this.filteredEntries();
+ const hubs = this.hubEntityIdSet();
+
+ if (hubs.size === 0) {
+ return filtered;
+ }
+
+ const pinnedHub = filtered.filter(e => hubs.has(e.entityID));
+ const rest = filtered.filter(e => !hubs.has(e.entityID));
+ return [...pinnedHub, ...rest];
+ });
+
+ private readonly searchBar = viewChild(WayfSearchBarComponent);
+ private readonly idpList = viewChild(WayfIdpListComponent);
+
+ constructor() {
+ // Set language when input changes
+ effect(() => {
+ const langInput = this.lang();
+ if (langInput) {
+ this.i18n.setLang(langInput);
+ }
+ });
+ }
+
+ ngOnInit(): void {
+ this.parseSamldsParams();
+ this.loadFeed();
+ }
+
+ onQueryChange(query: string): void {
+ this.searchQuery.set(query);
+ this.idpList()?.resetActive();
+ }
+
+ onIdpSelected(entry: IdpEntry): void {
+ this.persistence.selectIdp(entry.entityID);
+
+ // Always emit so parent components (e.g. login overlay) can handle redirect
+ this.idpSelected.emit(entry);
+
+ const params = this.samldsParams();
+ if (params.return) {
+ // SAMLDS redirect
+ const separator = params.return.includes('?') ? '&' : '?';
+ const redirectUrl = `${params.return}${separator}${encodeURIComponent(params.returnIDParam)}=${encodeURIComponent(entry.entityID)}`;
+ window.location.href = redirectUrl;
+ }
+ // If no SAMLDS return URL, the selection is just persisted.
+ // A parent component or DSpace can read it from localStorage.
+ }
+
+ onArrowDown(): void {
+ // Move focus from search bar into the list
+ this.idpList()?.activeIndex.set(0);
+ }
+
+ onFocusSearch(): void {
+ this.searchBar()?.focusInput();
+ }
+
+ onEscaped(): void {
+ this.searchQuery.set('');
+ }
+
+ private parseSamldsParams(): void {
+ const queryParams = this.route.snapshot.queryParams;
+ this.samldsParams.set({
+ entityID: queryParams['entityID'] ?? null,
+ return: queryParams['return'] ?? null,
+ returnIDParam: queryParams['returnIDParam'] ?? 'entityID',
+ isPassive: queryParams['isPassive'] === 'true',
+ });
+
+ // isPassive: if we have a last-used IdP, auto-select without UI
+ if (this.samldsParams().isPassive) {
+ const lastIdp = this.persistence.lastIdp();
+ if (lastIdp && this.samldsParams().return) {
+ const params = this.samldsParams();
+ const separator = params.return!.includes('?') ? '&' : '?';
+ const redirectUrl = `${params.return}${separator}${encodeURIComponent(params.returnIDParam)}=${encodeURIComponent(lastIdp)}`;
+ window.location.href = redirectUrl;
+ }
+ }
+ }
+
+ private loadFeed(): void {
+ // Prefer input binding, fallback to ?feedUrl= query param, then default mock feed
+ const url = this.feedUrl()
+ || this.route.snapshot.queryParams['feedUrl']
+ || 'assets/mock/wayf-feed.json';
+ this.feedService.loadFeed(url, this.categoryFilter());
+ }
+}
diff --git a/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.ts b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.ts
new file mode 100644
index 00000000000..e00d4a0aab7
--- /dev/null
+++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.ts
@@ -0,0 +1,127 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ output,
+} from '@angular/core';
+
+import { IdpEntry } from '../../models/idp-entry.model';
+import { WayfI18nService } from '../../services/i18n.service';
+import { WayfSearchService } from '../../services/search.service';
+
+/**
+ * Renders a single IdP entry card with logo, display name, and optional hub badge.
+ */
+@Component({
+ selector: 'ds-wayf-idp-card',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+ @if (logo(); as logoUrl) {
+
+
![]()
+
+ } @else {
+
+ {{ initials() }}
+
+ }
+
+
+
{{ displayName() }}
+
{{ entry().entityID }}
+
+
+ @if (isHub()) {
+
{{ i18n.t('wayf.hub.badge') }}
+ }
+
+ `,
+ styles: [`
+ .wayf-idp-card {
+ cursor: pointer;
+ border: 1px solid var(--bs-border-color, #dee2e6);
+ border-radius: 0.375rem;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+ }
+ .wayf-idp-card:hover,
+ .wayf-idp-card--active {
+ background-color: var(--bs-primary-bg-subtle, #e7f1ff);
+ border-color: var(--bs-primary, #0d6efd);
+ }
+ .wayf-idp-card--hub {
+ border-left: 3px solid var(--bs-info, #0dcaf0);
+ }
+ .wayf-idp-card__logo {
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ }
+ .wayf-idp-card__logo-img {
+ max-width: 40px;
+ max-height: 40px;
+ object-fit: contain;
+ }
+ .wayf-idp-card__logo--placeholder {
+ background-color: var(--bs-secondary-bg, #e9ecef);
+ border-radius: 0.25rem;
+ font-weight: 600;
+ font-size: 0.875rem;
+ color: var(--bs-secondary-color, #6c757d);
+ }
+ .wayf-idp-card__entity-id {
+ max-width: 300px;
+ }
+ `],
+})
+export class WayfIdpCardComponent {
+ protected readonly i18n = inject(WayfI18nService);
+ private readonly searchService = inject(WayfSearchService);
+
+ /** The IdP entry to display. */
+ readonly entry = input.required();
+
+ /** Whether this card is currently active/focused. */
+ readonly isActive = input(false);
+
+ /** Whether this IdP is a hub/proxy entity. */
+ readonly isHub = input(false);
+
+ /** Emits when the user selects this IdP. */
+ readonly selected = output();
+
+ /** Resolved display name in the active language. */
+ readonly displayName = computed(() =>
+ this.searchService.resolveDisplayName(this.entry().DisplayNames, this.i18n.lang()),
+ );
+
+ /** First suitable logo URL. */
+ readonly logo = computed(() => {
+ const logos = this.entry().Logos;
+ return logos?.[0]?.value ?? null;
+ });
+
+ /** Initials fallback when no logo is available. */
+ readonly initials = computed(() => {
+ const name = this.displayName();
+ const parts = name.split(/\s+/).filter(p => p.length > 0);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return (name.substring(0, 2)).toUpperCase();
+ });
+}
diff --git a/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.ts b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.ts
new file mode 100644
index 00000000000..54233f5cd05
--- /dev/null
+++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.ts
@@ -0,0 +1,115 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ output,
+ signal,
+} from '@angular/core';
+
+import { IdpEntry } from '../../models/idp-entry.model';
+import { WayfI18nService } from '../../services/i18n.service';
+import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component';
+
+/**
+ * Scrollable list of IdP cards with keyboard navigation.
+ */
+@Component({
+ selector: 'ds-wayf-idp-list',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [WayfIdpCardComponent],
+ template: `
+
+
+ @for (entry of entries(); track entry.entityID; let i = $index) {
+
+ }
+
+ @if (entries().length === 0 && !loading()) {
+
+ {{ i18n.t('wayf.search.no-results') }}
+
+ }
+
+
+
+ {{ i18n.t('wayf.a11y.result-count', { count: entries().length }) }}
+
+ `,
+ styles: [`
+ .wayf-idp-list {
+ max-height: 400px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+ `],
+})
+export class WayfIdpListComponent {
+ protected readonly i18n = inject(WayfI18nService);
+
+ /** Sorted/filtered entries to display. */
+ readonly entries = input.required();
+
+ /** Whether the feed is still loading. */
+ readonly loading = input(false);
+
+ /** Set of hub/proxy entityIDs for badge display. */
+ readonly hubEntityIds = input>(new Set());
+
+ /** Emits when an IdP is selected. */
+ readonly idpSelected = output();
+
+ /** Emits when focus should return to the search bar. */
+ readonly focusSearch = output();
+
+ /** Currently keyboard-focused index. */
+ readonly activeIndex = signal(-1);
+
+ onKeydown(event: KeyboardEvent): void {
+ const list = this.entries();
+ const current = this.activeIndex();
+
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault();
+ this.activeIndex.set(Math.min(current + 1, list.length - 1));
+ break;
+ case 'ArrowUp':
+ event.preventDefault();
+ if (current <= 0) {
+ this.activeIndex.set(-1);
+ this.focusSearch.emit();
+ } else {
+ this.activeIndex.set(current - 1);
+ }
+ break;
+ case 'Enter':
+ event.preventDefault();
+ if (current >= 0 && current < list.length) {
+ this.idpSelected.emit(list[current]);
+ }
+ break;
+ case 'Escape':
+ this.focusSearch.emit();
+ break;
+ }
+ }
+
+ /** Reset active index (e.g., when results change). */
+ resetActive(): void {
+ this.activeIndex.set(-1);
+ }
+}
diff --git a/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.ts b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.ts
new file mode 100644
index 00000000000..21df8ce0650
--- /dev/null
+++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.ts
@@ -0,0 +1,123 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+ output,
+} from '@angular/core';
+
+import { IdpEntry } from '../../models/idp-entry.model';
+import { WayfI18nService } from '../../services/i18n.service';
+import { WayfSearchService } from '../../services/search.service';
+
+/**
+ * Shows a shortcut card for the last-used IdP and a list of recent selections.
+ */
+@Component({
+ selector: 'ds-wayf-recent-idps',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+ @if (lastEntry()) {
+
+
+
+
+
+
+
{{ i18n.t('wayf.recent.continue') }}
+
{{ lastDisplayName() }}
+
+
+
+ }
+
+ @if (recentEntries().length > 1) {
+
+
{{ i18n.t('wayf.recent.title') }}
+ @for (entry of recentEntries(); track entry.entityID; let i = $index) {
+ @if (i > 0) {
+
+ {{ resolveName(entry) }}
+
+ }
+ }
+
+ }
+ `,
+ styles: [`
+ .wayf-recent__shortcut {
+ cursor: pointer;
+ background-color: var(--bs-primary-bg-subtle, #e7f1ff);
+ border-color: var(--bs-primary, #0d6efd) !important;
+ transition: background-color 0.15s ease;
+ }
+ .wayf-recent__shortcut:hover {
+ background-color: var(--bs-primary-bg-subtle, #cfe2ff);
+ }
+ .wayf-recent-list__item {
+ cursor: pointer;
+ }
+ .wayf-recent-list__item:hover {
+ background-color: var(--bs-tertiary-bg, #f8f9fa);
+ }
+ `],
+})
+export class WayfRecentIdpsComponent {
+ protected readonly i18n = inject(WayfI18nService);
+ private readonly searchService = inject(WayfSearchService);
+
+ /** All entries from the feed (needed to resolve names). */
+ readonly allEntries = input.required();
+
+ /** The entityID of the last selected IdP. */
+ readonly lastIdpEntityId = input(null);
+
+ /** List of recently selected entityIDs. */
+ readonly recentIdpEntityIds = input([]);
+
+ /** Emits when an IdP is selected via shortcut. */
+ readonly idpSelected = output();
+
+ /** The full IdP entry for the last selection. */
+ readonly lastEntry = computed(() => {
+ const lastId = this.lastIdpEntityId();
+ if (!lastId) {
+ return null;
+ }
+ return this.allEntries().find(e => e.entityID === lastId) ?? null;
+ });
+
+ /** Recent IdP entries (resolved from entityIDs). */
+ readonly recentEntries = computed(() => {
+ const ids = this.recentIdpEntityIds();
+ const all = this.allEntries();
+ return ids
+ .map(id => all.find(e => e.entityID === id))
+ .filter((e): e is IdpEntry => e !== undefined);
+ });
+
+ readonly lastDisplayName = computed(() => {
+ const entry = this.lastEntry();
+ if (!entry) {
+ return '';
+ }
+ return this.searchService.resolveDisplayName(entry.DisplayNames, this.i18n.lang());
+ });
+
+ resolveName(entry: IdpEntry): string {
+ return this.searchService.resolveDisplayName(entry.DisplayNames, this.i18n.lang());
+ }
+}
diff --git a/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.ts b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.ts
new file mode 100644
index 00000000000..3f58ae4a6fc
--- /dev/null
+++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.ts
@@ -0,0 +1,84 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ inject,
+ input,
+ output,
+ viewChild,
+} from '@angular/core';
+
+import { WayfI18nService } from '../../services/i18n.service';
+
+/**
+ * Search input bar for filtering IdP entries.
+ */
+@Component({
+ selector: 'ds-wayf-search-bar',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+
+
+
+
+
+
+ `,
+ styles: [`
+ .wayf-search-bar {
+ margin-bottom: 0.75rem;
+ }
+ .input-group-text {
+ background-color: var(--bs-body-bg, #fff);
+ }
+ `],
+})
+export class WayfSearchBarComponent {
+ protected readonly i18n = inject(WayfI18nService);
+
+ readonly inputId = 'wayf-search-input';
+
+ /** Current search value (two-way via parent). */
+ readonly value = input('');
+
+ /** Whether the result list has entries. */
+ readonly hasResults = input(false);
+
+ /** Emits the new query string on input. */
+ readonly queryChange = output();
+
+ /** Emits when arrow-down is pressed (to move focus into list). */
+ readonly arrowDown = output();
+
+ /** Emits when Escape is pressed. */
+ readonly escaped = output();
+
+ readonly searchInput = viewChild>('searchInput');
+
+ focusInput(): void {
+ this.searchInput()?.nativeElement.focus();
+ }
+
+ protected onInput(event: Event): void {
+ const value = (event.target as HTMLInputElement).value;
+ this.queryChange.emit(value);
+ }
+}
diff --git a/src/app/clarin-wayf/models/idp-entry.model.ts b/src/app/clarin-wayf/models/idp-entry.model.ts
new file mode 100644
index 00000000000..3fef92ce0c8
--- /dev/null
+++ b/src/app/clarin-wayf/models/idp-entry.model.ts
@@ -0,0 +1,30 @@
+/**
+ * Represents a localized string value from the IdP JSON feed.
+ */
+export interface LocalizedValue {
+ value: string;
+ lang: string;
+}
+
+/**
+ * Represents an IdP logo from the JSON feed.
+ */
+export interface IdpLogo {
+ value: string;
+ width?: number;
+ height?: number;
+}
+
+/**
+ * Represents a single Identity Provider entry from the SAML metadata feed.
+ * Matches the DiscoFeed JSON schema used by CLARIN SPF and other SAML federations.
+ */
+export interface IdpEntry {
+ entityID: string;
+ DisplayNames: LocalizedValue[];
+ Logos: IdpLogo[];
+ Keywords: LocalizedValue[];
+ InformationURLs: LocalizedValue[];
+ PrivacyStatementURLs: LocalizedValue[];
+ Tags: string[];
+}
diff --git a/src/app/clarin-wayf/models/wayf-config.model.ts b/src/app/clarin-wayf/models/wayf-config.model.ts
new file mode 100644
index 00000000000..6fc86084fd1
--- /dev/null
+++ b/src/app/clarin-wayf/models/wayf-config.model.ts
@@ -0,0 +1,35 @@
+/**
+ * Configuration for the WAYF component, derived from element attributes
+ * and URL query parameters (SAMLDS protocol).
+ */
+export interface WayfConfig {
+ /** URL to the JSON IdP feed (DiscoFeed). */
+ feedUrl: string;
+
+ /** Tag to filter IdPs by (e.g. "clarin"). */
+ categoryFilter: string | null;
+
+ /** JSON array of entityIDs to pin as proxy/hub IdPs. */
+ proxyEntities: string[];
+
+ /** Language code for UI and name resolution. */
+ lang: string;
+}
+
+/**
+ * SAMLDS protocol parameters extracted from the URL query string.
+ * See: https://wiki.oasis-open.org/security/IdpDiscoSvcProto
+ */
+export interface SamldsParams {
+ /** The entityID of the requesting Service Provider. */
+ entityID: string | null;
+
+ /** The URL to redirect to after IdP selection. */
+ return: string | null;
+
+ /** Query parameter name to append the selected IdP entityID (default: "entityID"). */
+ returnIDParam: string;
+
+ /** If true, component should attempt silent re-auth without user interaction. */
+ isPassive: boolean;
+}
diff --git a/src/app/clarin-wayf/services/feed.service.ts b/src/app/clarin-wayf/services/feed.service.ts
new file mode 100644
index 00000000000..07f1e6fc4f5
--- /dev/null
+++ b/src/app/clarin-wayf/services/feed.service.ts
@@ -0,0 +1,58 @@
+import {
+ inject,
+ Injectable,
+ signal,
+} from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { firstValueFrom } from 'rxjs';
+
+import { IdpEntry } from '../models/idp-entry.model';
+
+/**
+ * Service to fetch and cache the IdP feed from a DiscoFeed JSON endpoint.
+ */
+@Injectable({ providedIn: 'root' })
+export class WayfFeedService {
+
+ private readonly http = inject(HttpClient);
+
+ /** All IdP entries loaded from the feed (raw, unfiltered). */
+ readonly entries = signal([]);
+
+ /** Loading state. */
+ readonly loading = signal(false);
+
+ /** Error message if feed loading fails. */
+ readonly error = signal(null);
+
+ /**
+ * Fetch the IdP feed from the given URL, optionally filtering by tag.
+ */
+ async loadFeed(feedUrl: string, categoryFilter: string | null): Promise {
+ this.loading.set(true);
+ this.error.set(null);
+
+ try {
+ const raw = await firstValueFrom(
+ this.http.get(feedUrl),
+ );
+
+ let filtered = raw ?? [];
+
+ if (categoryFilter) {
+ const tag = categoryFilter.toLowerCase();
+ filtered = filtered.filter(entry =>
+ entry.Tags?.some(t => t.toLowerCase() === tag),
+ );
+ }
+
+ this.entries.set(filtered);
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Failed to load IdP feed';
+ this.error.set(message);
+ this.entries.set([]);
+ } finally {
+ this.loading.set(false);
+ }
+ }
+}
diff --git a/src/app/clarin-wayf/services/i18n.service.ts b/src/app/clarin-wayf/services/i18n.service.ts
new file mode 100644
index 00000000000..5e488092608
--- /dev/null
+++ b/src/app/clarin-wayf/services/i18n.service.ts
@@ -0,0 +1,104 @@
+import {
+ computed,
+ Injectable,
+ signal,
+} from '@angular/core';
+
+/**
+ * Internal i18n map keyed by language code → translation key → translated string.
+ * This avoids any dependency on Angular's i18n compiler or @ngx-translate at runtime,
+ * making the component fully self-contained for future extraction.
+ */
+const TRANSLATIONS: Record> = {
+ en: {
+ 'wayf.title': 'Select your institution',
+ 'wayf.search.placeholder': 'Search for your institution...',
+ 'wayf.search.results': '{count} institutions found',
+ 'wayf.search.no-results': 'No institutions match your search',
+ 'wayf.recent.continue': 'Continue with',
+ 'wayf.recent.title': 'Recent institutions',
+ 'wayf.hub.badge': 'Hub',
+ 'wayf.loading': 'Loading institutions...',
+ 'wayf.error.feed': 'Failed to load identity providers. Please try again.',
+ 'wayf.a11y.search-label': 'Search for your institution',
+ 'wayf.a11y.list-label': 'List of identity providers',
+ 'wayf.a11y.result-count': '{count} results available',
+ },
+ cs: {
+ 'wayf.title': 'Vyberte svou instituci',
+ 'wayf.search.placeholder': 'Hledejte svou instituci...',
+ 'wayf.search.results': '{count} institucí nalezeno',
+ 'wayf.search.no-results': 'Žádné instituce neodpovídají vašemu hledání',
+ 'wayf.recent.continue': 'Pokračovat s',
+ 'wayf.recent.title': 'Nedávné instituce',
+ 'wayf.hub.badge': 'Hub',
+ 'wayf.loading': 'Načítání institucí...',
+ 'wayf.error.feed': 'Nepodařilo se načíst poskytovatele identity. Zkuste to prosím znovu.',
+ 'wayf.a11y.search-label': 'Hledejte svou instituci',
+ 'wayf.a11y.list-label': 'Seznam poskytovatelů identity',
+ 'wayf.a11y.result-count': '{count} výsledků k dispozici',
+ },
+ de: {
+ 'wayf.title': 'Wählen Sie Ihre Einrichtung',
+ 'wayf.search.placeholder': 'Suchen Sie Ihre Einrichtung...',
+ 'wayf.search.results': '{count} Einrichtungen gefunden',
+ 'wayf.search.no-results': 'Keine Einrichtungen gefunden',
+ 'wayf.recent.continue': 'Weiter mit',
+ 'wayf.recent.title': 'Zuletzt verwendete Einrichtungen',
+ 'wayf.hub.badge': 'Hub',
+ 'wayf.loading': 'Einrichtungen werden geladen...',
+ 'wayf.error.feed': 'Identitätsanbieter konnten nicht geladen werden. Bitte versuchen Sie es erneut.',
+ 'wayf.a11y.search-label': 'Suchen Sie Ihre Einrichtung',
+ 'wayf.a11y.list-label': 'Liste der Identitätsanbieter',
+ 'wayf.a11y.result-count': '{count} Ergebnisse verfügbar',
+ },
+};
+
+/**
+ * Signal-based translation service for the WAYF component.
+ * Self-contained — no dependency on @ngx-translate or Angular i18n compiler.
+ */
+@Injectable({ providedIn: 'root' })
+export class WayfI18nService {
+
+ /** Active language code. */
+ readonly lang = signal(this.detectLang());
+
+ /** The active translation map. */
+ readonly translations = computed(() => {
+ const lang = this.lang();
+ return TRANSLATIONS[lang] ?? TRANSLATIONS['en'];
+ });
+
+ /**
+ * Translate a key, interpolating {placeholders} from the params map.
+ */
+ t(key: string, params?: Record): string {
+ let text = this.translations()[key] ?? TRANSLATIONS['en'][key] ?? key;
+ if (params) {
+ for (const [k, v] of Object.entries(params)) {
+ text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
+ }
+ }
+ return text;
+ }
+
+ /**
+ * Set the active language. Falls back to 'en' if not supported.
+ */
+ setLang(lang: string): void {
+ this.lang.set(lang);
+ }
+
+ private detectLang(): string {
+ try {
+ const browserLang = navigator?.language?.split('-')[0];
+ if (browserLang && TRANSLATIONS[browserLang]) {
+ return browserLang;
+ }
+ } catch {
+ // SSR — no navigator
+ }
+ return 'en';
+ }
+}
diff --git a/src/app/clarin-wayf/services/persistence.service.ts b/src/app/clarin-wayf/services/persistence.service.ts
new file mode 100644
index 00000000000..705e1315994
--- /dev/null
+++ b/src/app/clarin-wayf/services/persistence.service.ts
@@ -0,0 +1,76 @@
+import {
+ computed,
+ Injectable,
+ signal,
+} from '@angular/core';
+
+const STORAGE_KEY_LAST = 'clarin-wayf-last-idp';
+const STORAGE_KEY_RECENT = 'clarin-wayf-recent-idps';
+const MAX_RECENT = 5;
+
+/**
+ * Service for persisting IdP selections in localStorage.
+ * Tracks the last selected IdP and a history of recent selections.
+ */
+@Injectable({ providedIn: 'root' })
+export class WayfPersistenceService {
+
+ /** The entityID of the last selected IdP. */
+ readonly lastIdp = signal(this.readLast());
+
+ /** List of recently selected entityIDs (most recent first). */
+ readonly recentIdps = signal(this.readRecent());
+
+ /**
+ * Record an IdP selection: update last + push to recent list.
+ */
+ selectIdp(entityID: string): void {
+ // Update last
+ this.lastIdp.set(entityID);
+ this.writeLast(entityID);
+
+ // Update recent: remove if already present, prepend, trim to max
+ const recent = [entityID, ...this.recentIdps().filter(id => id !== entityID)].slice(0, MAX_RECENT);
+ this.recentIdps.set(recent);
+ this.writeRecent(recent);
+ }
+
+ private readLast(): string | null {
+ try {
+ return localStorage.getItem(STORAGE_KEY_LAST);
+ } catch {
+ return null;
+ }
+ }
+
+ private writeLast(entityID: string): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_LAST, entityID);
+ } catch {
+ // localStorage unavailable (SSR or quota exceeded)
+ }
+ }
+
+ private readRecent(): string[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY_RECENT);
+ if (raw) {
+ const parsed: unknown = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ return parsed.filter((item): item is string => typeof item === 'string').slice(0, MAX_RECENT);
+ }
+ }
+ } catch {
+ // Corrupted or unavailable
+ }
+ return [];
+ }
+
+ private writeRecent(entityIDs: string[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_RECENT, JSON.stringify(entityIDs));
+ } catch {
+ // localStorage unavailable
+ }
+ }
+}
diff --git a/src/app/clarin-wayf/services/search.service.spec.ts b/src/app/clarin-wayf/services/search.service.spec.ts
new file mode 100644
index 00000000000..3ea63a9b96f
--- /dev/null
+++ b/src/app/clarin-wayf/services/search.service.spec.ts
@@ -0,0 +1,242 @@
+import { TestBed } from '@angular/core/testing';
+
+import { IdpEntry } from '../models/idp-entry.model';
+import { WayfSearchService } from './search.service';
+
+/**
+ * Helper: build a minimal IdpEntry for testing.
+ */
+function makeEntry(overrides: Partial & { entityID: string }): IdpEntry {
+ return {
+ DisplayNames: [],
+ Logos: [],
+ Keywords: [],
+ InformationURLs: [],
+ PrivacyStatementURLs: [],
+ Tags: [],
+ ...overrides,
+ };
+}
+
+describe('WayfSearchService', () => {
+ let service: WayfSearchService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(WayfSearchService);
+ });
+
+ // ── normalize() ──────────────────────────────────────────────
+
+ describe('normalize()', () => {
+ it('should lowercase text', () => {
+ expect(service.normalize('HELLO')).toBe('hello');
+ });
+
+ it('should strip diacritics', () => {
+ expect(service.normalize('Příkladová Univerzita')).toBe('prikladova univerzita');
+ });
+
+ it('should strip accented characters (café → cafe)', () => {
+ expect(service.normalize('café')).toBe('cafe');
+ });
+
+ it('should handle German umlauts', () => {
+ expect(service.normalize('München')).toBe('munchen');
+ });
+
+ it('should collapse multiple spaces', () => {
+ expect(service.normalize(' foo bar ')).toBe('foo bar');
+ });
+
+ it('should handle empty string', () => {
+ expect(service.normalize('')).toBe('');
+ });
+ });
+
+ // ── extractDomain() ─────────────────────────────────────────
+
+ describe('extractDomain()', () => {
+ it('should extract hostname words from a URL', () => {
+ expect(service.extractDomain('https://idp.example.org/shibboleth')).toBe('idp example org');
+ });
+
+ it('should return empty string for invalid URL', () => {
+ expect(service.extractDomain('not-a-url')).toBe('');
+ });
+ });
+
+ // ── resolveDisplayName() ────────────────────────────────────
+
+ describe('resolveDisplayName()', () => {
+ const names = [
+ { value: 'Masaryk University', lang: 'en' },
+ { value: 'Masarykova univerzita', lang: 'cs' },
+ ];
+
+ it('should prefer the requested language', () => {
+ expect(service.resolveDisplayName(names, 'cs')).toBe('Masarykova univerzita');
+ });
+
+ it('should fallback to English when requested lang not present', () => {
+ expect(service.resolveDisplayName(names, 'de')).toBe('Masaryk University');
+ });
+
+ it('should fallback to first entry if no English', () => {
+ const noEn = [{ value: 'LMU München', lang: 'de' }];
+ expect(service.resolveDisplayName(noEn, 'fr')).toBe('LMU München');
+ });
+
+ it('should return empty string for empty array', () => {
+ expect(service.resolveDisplayName([], 'en')).toBe('');
+ });
+ });
+
+ // ── diceCoefficient() ──────────────────────────────────────
+
+ describe('diceCoefficient()', () => {
+ it('should return 1 for identical strings', () => {
+ expect(service.diceCoefficient('night', 'night')).toBe(1);
+ });
+
+ it('should return 0 for completely different strings', () => {
+ expect(service.diceCoefficient('abc', 'xyz')).toBe(0);
+ });
+
+ it('should return a value between 0 and 1 for similar strings', () => {
+ const score = service.diceCoefficient('night', 'nacht');
+ expect(score).toBeGreaterThan(0);
+ expect(score).toBeLessThan(1);
+ });
+
+ it('should return 1 for two empty strings', () => {
+ expect(service.diceCoefficient('', '')).toBe(1);
+ });
+
+ it('should handle single character strings (no bigrams)', () => {
+ // Single char → 0 bigrams, so the denominator is 0
+ expect(service.diceCoefficient('a', 'a')).toBe(1);
+ });
+
+ it('should score "masarky" vs "masaryk" highly (typo tolerance)', () => {
+ const score = service.diceCoefficient('masarky', 'masaryk');
+ expect(score).toBeGreaterThanOrEqual(0.6);
+ });
+ });
+
+ // ── scoreEntry() ───────────────────────────────────────────
+
+ describe('scoreEntry()', () => {
+ const masaryk = makeEntry({
+ entityID: 'https://shibboleth.muni.cz/idp/shibboleth',
+ DisplayNames: [
+ { value: 'Masaryk University', lang: 'en' },
+ { value: 'Masarykova univerzita', lang: 'cs' },
+ ],
+ Keywords: [{ value: 'masaryk brno czech republic muni', lang: 'en' }],
+ });
+
+ it('should return 1 for empty query (show all)', () => {
+ expect(service.scoreEntry(masaryk, '')).toBe(1);
+ });
+
+ it('should return 2 for exact substring match', () => {
+ expect(service.scoreEntry(masaryk, 'Masaryk')).toBe(2);
+ });
+
+ it('should return 2 for diacritics-normalized match', () => {
+ expect(service.scoreEntry(masaryk, 'masarykova')).toBe(2);
+ });
+
+ it('should match by entityID domain', () => {
+ const score = service.scoreEntry(masaryk, 'muni.cz');
+ expect(score).toBeGreaterThan(0);
+ });
+
+ it('should match by keyword', () => {
+ const score = service.scoreEntry(masaryk, 'brno');
+ expect(score).toBeGreaterThan(0);
+ });
+
+ it('should return 0 for completely unrelated query', () => {
+ expect(service.scoreEntry(masaryk, 'zzzzxxxx')).toBe(0);
+ });
+
+ it('should score a fuzzy typo above 0 when close enough', () => {
+ // "masarky" is a plausible typo for "masaryk"
+ const score = service.scoreEntry(masaryk, 'masarky');
+ expect(score).toBeGreaterThan(0);
+ });
+ });
+
+ // ── filterEntries() ────────────────────────────────────────
+
+ describe('filterEntries()', () => {
+ const entries: IdpEntry[] = [
+ makeEntry({
+ entityID: 'https://idp.example.org/shibboleth',
+ DisplayNames: [{ value: 'Example University', lang: 'en' }],
+ Keywords: [{ value: 'example research', lang: 'en' }],
+ }),
+ makeEntry({
+ entityID: 'https://shibboleth.muni.cz/idp/shibboleth',
+ DisplayNames: [
+ { value: 'Masaryk University', lang: 'en' },
+ { value: 'Masarykova univerzita', lang: 'cs' },
+ ],
+ Keywords: [{ value: 'masaryk brno czech republic', lang: 'en' }],
+ }),
+ makeEntry({
+ entityID: 'https://idp.cuni.cz/idp/shibboleth',
+ DisplayNames: [
+ { value: 'Charles University', lang: 'en' },
+ { value: 'Univerzita Karlova', lang: 'cs' },
+ ],
+ Keywords: [{ value: 'charles prague', lang: 'en' }],
+ }),
+ ];
+
+ it('should return all entries for empty query', () => {
+ expect(service.filterEntries(entries, '', 'en').length).toBe(3);
+ });
+
+ it('should filter to matching entries only', () => {
+ const result = service.filterEntries(entries, 'Masaryk', 'en');
+ expect(result.length).toBe(1);
+ expect(result[0].entityID).toBe('https://shibboleth.muni.cz/idp/shibboleth');
+ });
+
+ it('should match case-insensitively', () => {
+ const result = service.filterEntries(entries, 'masaryk', 'en');
+ expect(result.length).toBe(1);
+ });
+
+ it('should match diacritics-insensitively', () => {
+ // Searching "univerzita" matches both Czech names exactly,
+ // and also fuzzy-matches "University" via Dice coefficient
+ const result = service.filterEntries(entries, 'univerzita', 'en');
+ expect(result.length).toBe(3);
+ });
+
+ it('should return "University" entries for the generic term "University"', () => {
+ const result = service.filterEntries(entries, 'University', 'en');
+ expect(result.length).toBe(3);
+ });
+
+ it('should rank exact matches higher than partial matches', () => {
+ const result = service.filterEntries(entries, 'charles', 'en');
+ expect(result[0].entityID).toBe('https://idp.cuni.cz/idp/shibboleth');
+ });
+
+ it('should give a language bonus for matching the active language', () => {
+ // "univerzita karlova" in Czech → Charles University should rank first when lang=cs
+ const result = service.filterEntries(entries, 'Karlova', 'cs');
+ expect(result[0].entityID).toBe('https://idp.cuni.cz/idp/shibboleth');
+ });
+
+ it('should return empty array when nothing matches', () => {
+ const result = service.filterEntries(entries, 'zzzzxxxx', 'en');
+ expect(result.length).toBe(0);
+ });
+ });
+});
diff --git a/src/app/clarin-wayf/services/search.service.ts b/src/app/clarin-wayf/services/search.service.ts
new file mode 100644
index 00000000000..0f8300f85b5
--- /dev/null
+++ b/src/app/clarin-wayf/services/search.service.ts
@@ -0,0 +1,186 @@
+import {
+ computed,
+ Injectable,
+ signal,
+} from '@angular/core';
+
+import {
+ IdpEntry,
+ LocalizedValue,
+} from '../models/idp-entry.model';
+
+/**
+ * Fuzzy search service for filtering IdP entries.
+ * Handles diacritics normalization, typo tolerance via bigram similarity,
+ * and language-aware name resolution.
+ */
+@Injectable({ providedIn: 'root' })
+export class WayfSearchService {
+
+ /** Current search query. */
+ readonly query = signal('');
+
+ /** Active language for name resolution. */
+ readonly lang = signal(navigator?.language?.split('-')[0] ?? 'en');
+
+ /**
+ * Normalize a string for comparison: lowercase, strip diacritics, collapse whitespace.
+ */
+ normalize(text: string): string {
+ return text
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .trim()
+ .replace(/\s+/g, ' ');
+ }
+
+ /**
+ * Extract a readable domain hint from an entityID URL.
+ * e.g. "https://idp.example.org/shibboleth" → "idp example org"
+ */
+ extractDomain(entityID: string): string {
+ try {
+ const host = new URL(entityID).hostname;
+ return host.replace(/\./g, ' ');
+ } catch {
+ return '';
+ }
+ }
+
+ /**
+ * Resolve the best display name for an IdP in the given language.
+ * Falls back to English, then to the first available name.
+ */
+ resolveDisplayName(names: LocalizedValue[], lang: string): string {
+ const byLang = names.find(n => n.lang === lang);
+ if (byLang) {
+ return byLang.value;
+ }
+ const byEn = names.find(n => n.lang === 'en');
+ if (byEn) {
+ return byEn.value;
+ }
+ return names[0]?.value ?? '';
+ }
+
+ /**
+ * Collect all searchable text for an IdP entry.
+ */
+ getSearchableText(entry: IdpEntry): string {
+ const names = entry.DisplayNames.map(n => n.value);
+ const keywords = entry.Keywords.map(k => k.value);
+ const domain = this.extractDomain(entry.entityID);
+ return [...names, ...keywords, domain, entry.entityID].join(' ');
+ }
+
+ /**
+ * Generate character bigrams from a string.
+ */
+ private bigrams(text: string): Set {
+ const result = new Set();
+ for (let i = 0; i < text.length - 1; i++) {
+ result.add(text.substring(i, i + 2));
+ }
+ return result;
+ }
+
+ /**
+ * Sørensen–Dice coefficient for two strings (bigram similarity).
+ * Returns a value between 0 (no match) and 1 (identical).
+ */
+ diceCoefficient(a: string, b: string): number {
+ const bigramsA = this.bigrams(a);
+ const bigramsB = this.bigrams(b);
+ if (bigramsA.size === 0 && bigramsB.size === 0) {
+ return 1;
+ }
+ let intersection = 0;
+ for (const bg of bigramsA) {
+ if (bigramsB.has(bg)) {
+ intersection++;
+ }
+ }
+ return (2 * intersection) / (bigramsA.size + bigramsB.size);
+ }
+
+ /**
+ * Score an IdP entry against the current query.
+ * Returns a score between 0 (no match) and 1+ (strong match).
+ * A score of 0 means the entry should be filtered out.
+ */
+ scoreEntry(entry: IdpEntry, query: string): number {
+ if (!query) {
+ return 1; // No query = show all
+ }
+
+ const normalizedQuery = this.normalize(query);
+ const searchableText = this.normalize(this.getSearchableText(entry));
+
+ // Exact substring match → highest score
+ if (searchableText.includes(normalizedQuery)) {
+ return 2;
+ }
+
+ // Check individual query words against searchable text
+ const queryWords = normalizedQuery.split(' ').filter(w => w.length > 0);
+ let wordMatchCount = 0;
+ for (const word of queryWords) {
+ if (searchableText.includes(word)) {
+ wordMatchCount++;
+ }
+ }
+ if (wordMatchCount > 0) {
+ return 1 + (wordMatchCount / queryWords.length);
+ }
+
+ // Fuzzy match via Dice coefficient on individual words
+ const textWords = searchableText.split(' ');
+ let bestDice = 0;
+ for (const qWord of queryWords) {
+ if (qWord.length < 2) {
+ continue;
+ }
+ for (const tWord of textWords) {
+ const dice = this.diceCoefficient(qWord, tWord);
+ if (dice > bestDice) {
+ bestDice = dice;
+ }
+ }
+ }
+
+ // Threshold: only return fuzzy matches above 0.4 similarity
+ return bestDice >= 0.4 ? bestDice : 0;
+ }
+
+ /**
+ * Filter and rank IdP entries by the current query.
+ * Language-aware: prioritizes display name matches in the active language.
+ */
+ filterEntries(entries: IdpEntry[], query: string, lang: string): IdpEntry[] {
+ if (!query || query.trim().length === 0) {
+ return entries;
+ }
+
+ const scored = entries
+ .map(entry => {
+ let score = this.scoreEntry(entry, query);
+
+ // Bonus for matching in the active language display name
+ const localName = this.resolveDisplayName(entry.DisplayNames, lang);
+ if (localName) {
+ const normalizedName = this.normalize(localName);
+ const normalizedQuery = this.normalize(query);
+ if (normalizedName.includes(normalizedQuery)) {
+ score += 0.5;
+ }
+ }
+
+ return { entry, score };
+ })
+ .filter(item => item.score > 0)
+ .sort((a, b) => b.score - a.score);
+
+ return scored.map(item => item.entry);
+ }
+}
diff --git a/src/app/login-page/login-page.component.html b/src/app/login-page/login-page.component.html
index cde54f8fd7d..ef1c055a2f4 100644
--- a/src/app/login-page/login-page.component.html
+++ b/src/app/login-page/login-page.component.html
@@ -5,6 +5,37 @@
{{"login.form.header" | translate}}
+
+
+
+
+ @if (!wayfOpen()) {
+
+ }
+ @if (wayfOpen()) {
+
+
+
{{ 'login.wayf.header' | translate }}
+
+
+
+
+ }
+
diff --git a/src/app/login-page/login-page.component.ts b/src/app/login-page/login-page.component.ts
index 8835bcb8ce1..cff256eb23a 100644
--- a/src/app/login-page/login-page.component.ts
+++ b/src/app/login-page/login-page.component.ts
@@ -2,6 +2,7 @@ import {
Component,
OnDestroy,
OnInit,
+ signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -29,6 +30,9 @@ import {
isNotEmpty,
} from '../shared/empty.util';
import { ThemedLogInComponent } from '../shared/log-in/themed-log-in.component';
+import { ClarinWayfComponent } from '../clarin-wayf/clarin-wayf.component';
+import { IdpEntry } from '../clarin-wayf/models/idp-entry.model';
+import { HardRedirectService } from '../core/services/hard-redirect.service';
/**
* This component represents the login page
@@ -40,10 +44,16 @@ import { ThemedLogInComponent } from '../shared/log-in/themed-log-in.component';
imports: [
ThemedLogInComponent,
TranslateModule,
+ ClarinWayfComponent,
],
})
export class LoginPageComponent implements OnDestroy, OnInit {
+ /**
+ * Whether the WAYF institution picker is visible.
+ */
+ readonly wayfOpen = signal(false);
+
/**
* Subscription to unsubscribe onDestroy
* @type {Subscription}
@@ -55,9 +65,11 @@ export class LoginPageComponent implements OnDestroy, OnInit {
*
* @param {ActivatedRoute} route
* @param {Store} store
+ * @param {HardRedirectService} hardRedirectService
*/
constructor(private route: ActivatedRoute,
- private store: Store) {}
+ private store: Store,
+ private hardRedirectService: HardRedirectService) {}
/**
* Initialize instance variables
@@ -97,4 +109,17 @@ export class LoginPageComponent implements OnDestroy, OnInit {
// Clear all authentication messages when leaving login page
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
+
+ toggleWayf(): void {
+ this.wayfOpen.update(v => !v);
+ }
+
+ onIdpSelected(entry: IdpEntry): void {
+ // Redirect to /Shibboleth.sso/Login with the chosen IdP entityID.
+ // The SP handles the actual SAML AuthnRequest.
+ const origin = window.location.origin;
+ const returnUrl = encodeURIComponent(`${origin}/login`);
+ const ssoUrl = `${origin}/Shibboleth.sso/Login?entityID=${encodeURIComponent(entry.entityID)}&target=${returnUrl}`;
+ this.hardRedirectService.redirect(ssoUrl);
+ }
}
diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
index 5f5a89db4db..bcc6a4fba4c 100644
--- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
+++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html
@@ -21,8 +21,48 @@
role="dialog"
aria-modal="true"
[attr.aria-label]="'nav.login' | translate">
-
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+ @if (activeLoginTab() === 'local') {
+
+
+
+ }
+ @if (activeLoginTab() === 'institution') {
+
+
+
+ }
diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss
index 7d4ec043aca..7c59f8bfed2 100644
--- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss
+++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss
@@ -1,10 +1,27 @@
#loginDropdownMenu, #logoutDropdownMenu {
- min-width: 330px;
+ min-width: 400px;
z-index: 1002;
}
#loginDropdownMenu {
min-height: 75px;
+ max-height: 80vh;
+ overflow-y: auto;
+}
+
+.wayf-login-tabs {
+ .nav-tabs {
+ border-bottom: none;
+ }
+ .nav-link {
+ font-size: 0.875rem;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ color: var(--bs-body-color, #212529);
+ &.active {
+ font-weight: 600;
+ }
+ }
}
.dropdown-item.active, .dropdown-item:active,
diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts
index ccf536b9a9d..568df267a6e 100644
--- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts
+++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts
@@ -5,6 +5,7 @@ import {
import {
Component,
OnInit,
+ signal,
} from '@angular/core';
import {
RouterLink,
@@ -41,6 +42,9 @@ import {
isAuthenticationLoading,
} from '../../core/auth/selectors';
import { EPerson } from '../../core/eperson/models/eperson.model';
+import { HardRedirectService } from '../../core/services/hard-redirect.service';
+import { ClarinWayfComponent } from '../../clarin-wayf/clarin-wayf.component';
+import { IdpEntry } from '../../clarin-wayf/models/idp-entry.model';
import {
fadeInOut,
fadeOut,
@@ -59,6 +63,7 @@ import { ThemedUserMenuComponent } from './user-menu/themed-user-menu.component'
imports: [
AsyncPipe,
BrowserOnlyPipe,
+ ClarinWayfComponent,
NgbDropdownModule,
NgClass,
RouterLink,
@@ -89,9 +94,13 @@ export class AuthNavMenuComponent implements OnInit {
public sub: Subscription;
+ /** Active login tab: 'local' for password form, 'institution' for WAYF picker. */
+ readonly activeLoginTab = signal<'local' | 'institution'>('local');
+
constructor(private store: Store,
private windowService: HostWindowService,
private authService: AuthService,
+ protected hardRedirectService: HardRedirectService,
) {
this.isMobile$ = this.windowService.isMobile();
}
@@ -113,4 +122,15 @@ export class AuthNavMenuComponent implements OnInit {
),
);
}
+
+ switchLoginTab(tab: 'local' | 'institution'): void {
+ this.activeLoginTab.set(tab);
+ }
+
+ onIdpSelected(entry: IdpEntry): void {
+ const origin = window.location.origin;
+ const returnUrl = encodeURIComponent(this.hardRedirectService.getCurrentRoute());
+ const ssoUrl = `${origin}/Shibboleth.sso/Login?entityID=${encodeURIComponent(entry.entityID)}&target=${returnUrl}`;
+ this.hardRedirectService.redirect(ssoUrl);
+ }
}
diff --git a/src/app/shared/log-in/methods/auth-methods.type.ts b/src/app/shared/log-in/methods/auth-methods.type.ts
index 5b68f0b2e6b..271a98eaf5e 100644
--- a/src/app/shared/log-in/methods/auth-methods.type.ts
+++ b/src/app/shared/log-in/methods/auth-methods.type.ts
@@ -1,6 +1,8 @@
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
import { LogInPasswordComponent } from './password/log-in-password.component';
+import { LogInShibbolethWayfComponent } from './shibboleth-wayf/log-in-shibboleth-wayf.component';
export type AuthMethodTypeComponent =
typeof LogInPasswordComponent |
- typeof LogInExternalProviderComponent;
+ typeof LogInExternalProviderComponent |
+ typeof LogInShibbolethWayfComponent;
diff --git a/src/app/shared/log-in/methods/log-in.methods-decorator.ts b/src/app/shared/log-in/methods/log-in.methods-decorator.ts
index e17fff856a6..5d9e8256bce 100644
--- a/src/app/shared/log-in/methods/log-in.methods-decorator.ts
+++ b/src/app/shared/log-in/methods/log-in.methods-decorator.ts
@@ -2,10 +2,11 @@ import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
import { AuthMethodTypeComponent } from './auth-methods.type';
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
import { LogInPasswordComponent } from './password/log-in-password.component';
+import { LogInShibbolethWayfComponent } from './shibboleth-wayf/log-in-shibboleth-wayf.component';
export const AUTH_METHOD_FOR_DECORATOR_MAP = new Map([
[AuthMethodType.Password, LogInPasswordComponent],
- [AuthMethodType.Shibboleth, LogInExternalProviderComponent],
+ [AuthMethodType.Shibboleth, LogInShibbolethWayfComponent],
[AuthMethodType.Oidc, LogInExternalProviderComponent],
[AuthMethodType.Orcid, LogInExternalProviderComponent],
[AuthMethodType.Saml, LogInExternalProviderComponent],
diff --git a/src/app/shared/log-in/methods/shibboleth-wayf/log-in-shibboleth-wayf.component.ts b/src/app/shared/log-in/methods/shibboleth-wayf/log-in-shibboleth-wayf.component.ts
new file mode 100644
index 00000000000..5ad3d029d5f
--- /dev/null
+++ b/src/app/shared/log-in/methods/shibboleth-wayf/log-in-shibboleth-wayf.component.ts
@@ -0,0 +1,166 @@
+import {
+ Component,
+ Inject,
+ OnInit,
+ signal,
+} from '@angular/core';
+import {
+ select,
+ Store,
+} from '@ngrx/store';
+import { TranslateModule } from '@ngx-translate/core';
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { AuthService } from '../../../../core/auth/auth.service';
+import { AuthMethod } from '../../../../core/auth/models/auth.method';
+import {
+ isAuthenticated,
+ isAuthenticationLoading,
+} from '../../../../core/auth/selectors';
+import { CoreState } from '../../../../core/core-state.model';
+import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
+import {
+ NativeWindowRef,
+ NativeWindowService,
+} from '../../../../core/services/window.service';
+import { isEmpty } from '../../../empty.util';
+import { IdpEntry } from '../../../../clarin-wayf/models/idp-entry.model';
+import { ClarinWayfComponent } from '../../../../clarin-wayf/clarin-wayf.component';
+
+/**
+ * Shibboleth login method that shows the CLARIN WAYF (Where Are You From)
+ * identity provider picker as an overlay within the login page.
+ *
+ * Instead of hard-redirecting to the SP's Shibboleth handler,
+ * this component opens an inline WAYF panel where the user can search
+ * and select their identity provider. After selection, the user is
+ * redirected to the Shibboleth handler with the chosen IdP's entityID.
+ */
+@Component({
+ selector: 'ds-log-in-shibboleth-wayf',
+ imports: [
+ TranslateModule,
+ ClarinWayfComponent,
+ ],
+ template: `
+
+ @if (!wayfOpen()) {
+
+ }
+
+
+ @if (wayfOpen()) {
+
+
+
+
+ }
+ `,
+ styles: [`
+ .wayf-overlay {
+ border: 1px solid var(--bs-border-color, #dee2e6);
+ border-radius: 0.375rem;
+ padding: 1rem;
+ margin-top: 0.5rem;
+ background-color: var(--bs-body-bg, #fff);
+ max-height: 500px;
+ overflow-y: auto;
+ }
+ `],
+})
+export class LogInShibbolethWayfComponent implements OnInit {
+
+ public authMethod: AuthMethod;
+
+ public loading: Observable;
+
+ /** The Shibboleth handler location URL from the backend. */
+ public location: string;
+
+ public isAuthenticated: Observable;
+
+ /** Whether the WAYF overlay is open. */
+ readonly wayfOpen = signal(false);
+
+ /** Feed URL for the WAYF component. Falls back to mock for development. */
+ feedUrl = 'assets/mock/wayf-feed.json';
+
+ constructor(
+ @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod,
+ @Inject('isStandalonePage') public isStandalonePage: boolean,
+ @Inject(NativeWindowService) protected _window: NativeWindowRef,
+ private authService: AuthService,
+ private hardRedirectService: HardRedirectService,
+ private store: Store,
+ ) {
+ this.authMethod = injectedAuthMethodModel;
+ }
+
+ ngOnInit(): void {
+ this.isAuthenticated = this.store.pipe(select(isAuthenticated));
+ this.loading = this.store.pipe(select(isAuthenticationLoading));
+ this.location = decodeURIComponent(this.injectedAuthMethodModel.location);
+ }
+
+ openWayf(): void {
+ this.wayfOpen.set(true);
+ }
+
+ closeWayf(): void {
+ this.wayfOpen.set(false);
+ }
+
+ /**
+ * Called when the user selects an IdP from the WAYF component.
+ * Constructs the Shibboleth handler redirect URL with the chosen entityID,
+ * similar to the original SAMLDS protocol flow.
+ */
+ onIdpSelected(entry: IdpEntry): void {
+ this.authService.getRedirectUrl().pipe(take(1)).subscribe((redirectRoute) => {
+ if (!this.isStandalonePage) {
+ redirectRoute = this.hardRedirectService.getCurrentRoute();
+ } else if (isEmpty(redirectRoute)) {
+ redirectRoute = '/';
+ }
+
+ // Build the Shibboleth redirect URL.
+ // The location from the backend is the SP's Shibboleth SSO endpoint.
+ // We append the chosen IdP's entityID so the SP knows which IdP to use.
+ const externalServerUrl = this.authService.getExternalServerRedirectUrl(
+ this._window.nativeWindow.origin,
+ redirectRoute,
+ this.location,
+ );
+
+ // Append entityID parameter to the redirect URL
+ const separator = externalServerUrl.includes('?') ? '&' : '?';
+ const finalUrl = `${externalServerUrl}${separator}entityID=${encodeURIComponent(entry.entityID)}`;
+
+ this.hardRedirectService.redirect(finalUrl);
+ });
+ }
+
+ getButtonLabel(): string {
+ return `login.form.${this.authMethod.authMethodType}`;
+ }
+}
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index c0b620d24d7..9ddcc4e5fa8 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -3415,6 +3415,16 @@
"login.breadcrumbs": "Login",
+ "login.wayf.button": "Log in via your institution",
+
+ "login.wayf.header": "Select Your Institution",
+
+ "login.wayf.close": "Close institution picker",
+
+ "wayf.title": "Select Your Institution",
+
+ "wayf.breadcrumbs": "Identity Provider Selection",
+
"logout.form.header": "Log out from DSpace",
"logout.form.submit": "Log out",
@@ -3701,6 +3711,10 @@
"nav.login": "Log In",
+ "nav.login.tab.local": "Local Login",
+
+ "nav.login.tab.institution": "Institution",
+
"nav.user-profile-menu-and-logout": "User profile menu and log out",
"nav.logout": "Log Out",
diff --git a/src/assets/mock/wayf-feed.json b/src/assets/mock/wayf-feed.json
new file mode 100644
index 00000000000..045c6d2c269
--- /dev/null
+++ b/src/assets/mock/wayf-feed.json
@@ -0,0 +1,133 @@
+[
+ {
+ "entityID": "https://idp.example.org/shibboleth",
+ "DisplayNames": [
+ {"value": "Example University", "lang": "en"},
+ {"value": "Příkladová Univerzita", "lang": "cs"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/0d6efd/white?text=EU", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "example university research", "lang": "en"}],
+ "InformationURLs": [{"value": "https://idp.example.org/info", "lang": "en"}],
+ "PrivacyStatementURLs": [{"value": "https://idp.example.org/privacy", "lang": "en"}],
+ "Tags": ["clarin", "sirtfi"]
+ },
+ {
+ "entityID": "https://shibboleth.muni.cz/idp/shibboleth",
+ "DisplayNames": [
+ {"value": "Masaryk University", "lang": "en"},
+ {"value": "Masarykova univerzita", "lang": "cs"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/dc3545/white?text=MU", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "masaryk brno czech republic muni", "lang": "en"}],
+ "InformationURLs": [{"value": "https://www.muni.cz", "lang": "en"}],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin", "sirtfi"]
+ },
+ {
+ "entityID": "https://login.cesnet.cz/idp/",
+ "DisplayNames": [
+ {"value": "CESNET e-Infrastructure", "lang": "en"},
+ {"value": "e-Infrastruktura CESNET", "lang": "cs"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/198754/white?text=CE", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "cesnet czech research network e-infra", "lang": "en"}],
+ "InformationURLs": [{"value": "https://www.cesnet.cz", "lang": "en"}],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin"]
+ },
+ {
+ "entityID": "https://login.feld.cvut.cz/idp/shibboleth",
+ "DisplayNames": [
+ {"value": "Czech Technical University in Prague", "lang": "en"},
+ {"value": "České vysoké učení technické v Praze", "lang": "cs"}
+ ],
+ "Logos": [],
+ "Keywords": [{"value": "cvut ctu prague czech technical", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin", "sirtfi"]
+ },
+ {
+ "entityID": "https://idp.cuni.cz/idp/shibboleth",
+ "DisplayNames": [
+ {"value": "Charles University", "lang": "en"},
+ {"value": "Univerzita Karlova", "lang": "cs"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/6f42c1/white?text=UK", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "cuni charles prague czech karlov", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin", "sirtfi"]
+ },
+ {
+ "entityID": "https://idp.ub.uni-muenchen.de/shibboleth",
+ "DisplayNames": [
+ {"value": "Ludwig-Maximilians-Universität München", "lang": "de"},
+ {"value": "LMU Munich", "lang": "en"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/ffc107/black?text=LMU", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "lmu munich münchen bavaria germany", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin"]
+ },
+ {
+ "entityID": "https://login.kuleuven.be/idp/shibboleth",
+ "DisplayNames": [
+ {"value": "KU Leuven", "lang": "en"},
+ {"value": "KU Leuven", "lang": "nl"}
+ ],
+ "Logos": [],
+ "Keywords": [{"value": "ku leuven belgium catholic university", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin"]
+ },
+ {
+ "entityID": "https://aai.perun-aai.org/idp/",
+ "DisplayNames": [
+ {"value": "Perun MyAccessID", "lang": "en"},
+ {"value": "Perun MyAccessID", "lang": "cs"}
+ ],
+ "Logos": [
+ {"value": "https://placehold.co/80x60/20c997/white?text=Perun", "width": 80, "height": 60}
+ ],
+ "Keywords": [{"value": "perun myaccessid proxy hub aai", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin"]
+ },
+ {
+ "entityID": "https://cafe.rnp.br/idp/",
+ "DisplayNames": [
+ {"value": "Café - Federação Brasileira", "lang": "en"}
+ ],
+ "Logos": [],
+ "Keywords": [{"value": "cafe brazil rnp federation brasileiro", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin"]
+ },
+ {
+ "entityID": "https://idp.uw.edu.pl/idp/shibboleth",
+ "DisplayNames": [
+ {"value": "University of Warsaw", "lang": "en"},
+ {"value": "Uniwersytet Warszawski", "lang": "pl"}
+ ],
+ "Logos": [],
+ "Keywords": [{"value": "warsaw poland university uw", "lang": "en"}],
+ "InformationURLs": [],
+ "PrivacyStatementURLs": [],
+ "Tags": ["clarin", "sirtfi"]
+ }
+]
diff --git a/src/themes/custom/app/login-page/login-page.component.ts b/src/themes/custom/app/login-page/login-page.component.ts
index 9bb57b59693..f84f58f311e 100644
--- a/src/themes/custom/app/login-page/login-page.component.ts
+++ b/src/themes/custom/app/login-page/login-page.component.ts
@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { ThemedLogInComponent } from 'src/app/shared/log-in/themed-log-in.component';
+import { ClarinWayfComponent } from '../../../../app/clarin-wayf/clarin-wayf.component';
import { LoginPageComponent as BaseComponent } from '../../../../app/login-page/login-page.component';
@@ -13,6 +14,7 @@ import { LoginPageComponent as BaseComponent } from '../../../../app/login-page/
imports: [
ThemedLogInComponent,
TranslateModule,
+ ClarinWayfComponent,
],
})
export class LoginPageComponent extends BaseComponent {
diff --git a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts
index e912de8d83b..fec5c5fb3ad 100644
--- a/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts
+++ b/src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts
@@ -12,6 +12,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { ThemedUserMenuComponent } from 'src/app/shared/auth-nav-menu/user-menu/themed-user-menu.component';
import { ThemedLogInComponent } from 'src/app/shared/log-in/themed-log-in.component';
+import { ClarinWayfComponent } from '../../../../../app/clarin-wayf/clarin-wayf.component';
import {
fadeInOut,
fadeOut,
@@ -29,6 +30,7 @@ import { BrowserOnlyPipe } from '../../../../../app/shared/utils/browser-only.pi
imports: [
AsyncPipe,
BrowserOnlyPipe,
+ ClarinWayfComponent,
NgbDropdownModule,
NgClass,
RouterLink,
From 65572a91f80df6592a970df6e9b94b82a0479ace Mon Sep 17 00:00:00 2001
From: Juraj Roka <95219754+jr-rk@users.noreply.github.com>
Date: Wed, 11 Mar 2026 16:18:58 +0100
Subject: [PATCH 2/3] add AGENTS.md
---
src/app/clarin-wayf/AGENTS.md | 139 ++++++++++++++++++++++++++++++++++
1 file changed, 139 insertions(+)
create mode 100644 src/app/clarin-wayf/AGENTS.md
diff --git a/src/app/clarin-wayf/AGENTS.md b/src/app/clarin-wayf/AGENTS.md
new file mode 100644
index 00000000000..f1cb0c52197
--- /dev/null
+++ b/src/app/clarin-wayf/AGENTS.md
@@ -0,0 +1,139 @@
+# CLARIN WAYF — Agent Context
+
+This document captures all context needed to continue work on this feature.
+
+---
+
+## What This Is
+
+A **CLARIN WAYF (Where Are You From)** Identity Provider (IdP) picker, implemented as a standalone Angular component inside DSpace Angular 9.2.
+
+It replaces the legacy external DiscoJuice/jQuery solution. Instead of redirecting to a separately deployed discovery service, the IdP selection UI is now embedded directly inside the DSpace frontend — on the `/login` page and in the header dropdown.
+
+The eventual goal is to extract this into a standalone Angular Elements Web Component (``), but for now it lives here for development and design iteration.
+
+---
+
+## Component Location
+
+```
+src/app/clarin-wayf/
+├── AGENTS.md ← this file
+├── clarin-wayf.component.ts ← main orchestrator component
+├── clarin-wayf-routes.ts ← standalone route at /wayf
+├── models/
+│ ├── idp-entry.model.ts ← IdpEntry, LocalizedValue, IdpLogo interfaces
+│ └── wayf-config.model.ts ← WayfConfig, SamldsParams types
+├── services/
+│ ├── search.service.ts ← fuzzy search engine (Sørensen–Dice)
+│ ├── search.service.spec.ts ← 33 unit tests (all passing)
+│ ├── feed.service.ts ← HTTP fetch + cache of IdP JSON feed
+│ ├── persistence.service.ts ← localStorage (last IdP, up to 5 recent)
+│ └── i18n.service.ts ← signal-based translation (en/cs/de)
+└── components/
+ ├── idp-card/
+ │ └── wayf-idp-card.component.ts ← single IdP card (logo, name, tag badge)
+ ├── search-bar/
+ │ └── wayf-search-bar.component.ts ← search input with ARIA combobox
+ ├── idp-list/
+ │ └── wayf-idp-list.component.ts ← virtualized/filtered list of IdP cards
+ └── recent-idps/
+ └── wayf-recent-idps.component.ts ← strip of recently used IdPs
+```
+
+---
+
+## Integration Points (Files Modified Outside This Folder)
+
+### 1. Standalone Route
+- **`src/app/app-routes.ts`** — added lazy route `/wayf` → `clarin-wayf-routes.ts`
+
+### 2. Login Page (`/login`)
+- **`src/app/login-page/login-page.component.ts`** — added `wayfOpen` signal, `toggleWayf()`, `onIdpSelected()`, `HardRedirectService` injection
+- **`src/app/login-page/login-page.component.html`** — divider + toggle button + collapsible `` panel below password form
+- **`src/themes/custom/app/login-page/login-page.component.ts`** — added `ClarinWayfComponent` to `imports` (themed wrapper)
+
+### 3. Header Dropdown Login
+- **`src/app/shared/auth-nav-menu/auth-nav-menu.component.ts`** — added `activeLoginTab` signal, `ClarinWayfComponent`, `HardRedirectService`, tab switching logic
+- **`src/app/shared/auth-nav-menu/auth-nav-menu.component.html`** — replaced single `` with two-tab layout: "Local Login" + "Institution"
+- **`src/app/shared/auth-nav-menu/auth-nav-menu.component.scss`** — widened dropdown to 400px, added `.wayf-login-tabs` styling
+- **`src/themes/custom/app/shared/auth-nav-menu/auth-nav-menu.component.ts`** — added `ClarinWayfComponent` to `imports`
+
+### 4. Shibboleth Auth Method (for backends with Shibboleth configured)
+- **`src/app/shared/log-in/methods/shibboleth-wayf/log-in-shibboleth-wayf.component.ts`** — new component; replaces hard-redirect button with inline WAYF
+- **`src/app/shared/log-in/methods/log-in.methods-decorator.ts`** — `AuthMethodType.Shibboleth` now maps to `LogInShibbolethWayfComponent`
+- **`src/app/shared/log-in/methods/auth-methods.type.ts`** — added `typeof LogInShibbolethWayfComponent` to union type
+
+### 5. i18n
+- **`src/assets/i18n/en.json5`** — added keys:
+ - `wayf.title`, `wayf.breadcrumbs`
+ - `login.wayf.button`, `login.wayf.header`, `login.wayf.close`
+ - `nav.login.tab.local`, `nav.login.tab.institution`
+
+### 6. Mock Feed
+- **`src/assets/mock/wayf-feed.json`** — 10 sample IdPs (MUNI, CESNET, Charles University, CVUT, LMU, KU Leuven, Perun, Café Brazil, UW, Example University)
+
+---
+
+## Key Design Decisions
+
+### SAMLDS Protocol
+On IdP selection, the component builds a Shibboleth SP redirect URL:
+```
+/Shibboleth.sso/Login?entityID=&target=
+```
+`onIdpSelected()` in both `login-page.component.ts` and `auth-nav-menu.component.ts` calls `hardRedirectService.redirect()` with this URL.
+
+### Feed Loading (`clarin-wayf.component.ts`)
+Feed URL resolved in this priority order:
+1. `feedUrl` input binding (parent passes it)
+2. `?feedUrl=` query parameter (for standalone `/wayf` route)
+3. Falls back to `assets/mock/wayf-feed.json` for local development
+
+### Fuzzy Search (`search.service.ts`)
+- Diacritics normalized via `NFD` + strip combining marks
+- Sørensen–Dice bigram similarity coefficient (no external deps)
+- Scoring: exact match = 2, word boundary = 1 + ratio, fuzzy ≥ 0.4 threshold
+- Language-aware: preferred `lang` gets small ranking bonus
+
+### Persistence (`persistence.service.ts`)
+- `clarin-wayf-last-idp` key — entityID of last selected IdP
+- `clarin-wayf-recent-idps` key — JSON array, max 5 entries, newest first
+
+### Angular Patterns Used
+- **Standalone components** throughout (no NgModules)
+- **`inject()`** exclusively (no constructor injection)
+- **Signals** for all reactive state (`signal()`, `computed()`)
+- **`input()`/`output()`** for component I/O (Angular 17+ API)
+- **`@if`/`@for`** control flow (Angular 17+ template syntax)
+- **OnPush** change detection
+
+---
+
+## Running Tests
+
+```bash
+npm test -- --include='src/app/clarin-wayf/**/*.spec.ts'
+```
+
+All 33 tests in `search.service.spec.ts` should pass.
+
+---
+
+## TODO / Next Steps
+
+- [ ] **Production feed URL**: Replace `assets/mock/wayf-feed.json` default with the actual CLARIN feed (e.g. `https://ds.aai.cesnet.cz/feeds/CLARIN_SP_Feed.json`)
+- [ ] **Shibboleth SP path**: Verify `/Shibboleth.sso/Login` matches the actual SP endpoint in the target deployment; make it configurable via `environment.ts`
+- [ ] **Proxy/Hub IdPs**: Wire `proxyEntities` input with actual CLARIN hub entityIDs so they pin to the top of the list with a badge
+- [ ] **Visual polish**: The component currently uses minimal Bootstrap 5 CSS variables; full UX design pass needed
+- [ ] **Component tests**: Only `search.service.spec.ts` exists; add specs for `feed.service.ts`, `persistence.service.ts`, and `clarin-wayf.component.ts`
+- [ ] **Angular Elements extraction**: Once stable, extract into a separate library and package as `` custom element (single `