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()) { + + } + + @if (!feedService.loading() && !feedService.error()) { + + + + + + + + @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 { + + } + +
+
{{ 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 `