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 01/17] 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 02/17] 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 `'); + expect(result).toBeNull(); + }); + + it('should reject malformed URLs', () => { + const result = (component as any).sanitizeReturnUrl('not-a-url'); + expect(result).toBeNull(); + }); + + it('should handle null input', () => { + const result = (component as any).sanitizeReturnUrl(null); + expect(result).toBeNull(); + }); + + it('should handle empty string', () => { + const result = (component as any).sanitizeReturnUrl(''); + expect(result).toBeNull(); + }); + }); + + describe('feedUrl validation', () => { + it('should NOT fetch when feedUrl has javascript: scheme', async () => { + fetchSpy.calls.reset(); + + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ClarinWayfComponent], + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { + provide: WAYF_CONFIG, + useValue: { ...TEST_CONFIG, feedUrl: 'javascript:alert(1)' }, + }, + { provide: ActivatedRoute, useValue: mockActivatedRoute() }, + ], + }).compileComponents(); + + const f = TestBed.createComponent(ClarinWayfComponent); + f.detectChanges(); + await f.whenStable(); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + // ── SAMLDS params ─────────────────────────────────────────── + + describe('SAMLDS parameter parsing', () => { + it('should parse entityID from query params', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ClarinWayfComponent], + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: WAYF_CONFIG, useValue: TEST_CONFIG }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute({ + entityID: 'https://sp.example.org', + return: 'https://sp.example.org/return', + }), + }, + ], + }).compileComponents(); + + const f = TestBed.createComponent(ClarinWayfComponent); + f.detectChanges(); + + expect(f.componentInstance.samldsParams().entityID).toBe('https://sp.example.org'); + expect(f.componentInstance.samldsParams().return).toBe('https://sp.example.org/return'); + }); + + it('should reject non-HTTPS return URLs in SAMLDS', async () => { + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ClarinWayfComponent], + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: WAYF_CONFIG, useValue: TEST_CONFIG }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute({ + return: 'javascript:alert(1)', + }), + }, + ], + }).compileComponents(); + + const f = TestBed.createComponent(ClarinWayfComponent); + f.detectChanges(); + + expect(f.componentInstance.samldsParams().return).toBeNull(); + }); + }); + + // ── Config resolution ─────────────────────────────────────── + + describe('config resolution', () => { + it('should resolve serviceName from WAYF_CONFIG', () => { + fixture.detectChanges(); + expect(component.resolvedServiceName()).toBe(TEST_CONFIG.serviceName); + }); + + it('should resolve maxResults from WAYF_CONFIG', () => { + fixture.detectChanges(); + expect(component.resolvedMaxResults()).toBe(TEST_CONFIG.maxResults); + }); + }); + + // ── Event emitters ────────────────────────────────────────── + + describe('onIdpSelected()', () => { + it('should emit idpSelected event', () => { + fixture.detectChanges(); + const spy = jasmine.createSpy('idpSelected'); + component.idpSelected.subscribe(spy); + + const mockIdp = { entityID: 'https://idp.example.org', title: 'Test' }; + component.onIdpSelected(mockIdp); + + expect(spy).toHaveBeenCalledWith(mockIdp); + }); + }); + + describe('search', () => { + it('should update searchQuery on onQueryChange', () => { + fixture.detectChanges(); + component.onQueryChange('test query'); + expect(component.searchQuery()).toBe('test query'); + }); + + it('should clear searchQuery on onEscaped', () => { + fixture.detectChanges(); + component.onQueryChange('foo'); + component.onEscaped(); + expect(component.searchQuery()).toBe(''); + }); + }); + + describe('isPassive mode', () => { + afterEach(() => localStorage.removeItem('wayf:last-idp')); + + it('should build redirect URL with last IdP when isPassive and return URL are set', async () => { + localStorage.setItem('wayf:last-idp', 'https://idp.example.org/shibboleth'); + + await TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ClarinWayfComponent], + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: WAYF_CONFIG, useValue: TEST_CONFIG }, + { provide: ActivatedRoute, useValue: mockActivatedRoute({ isPassive: 'true', return: 'https://sp.example.org/return' }) }, + ], + }).compileComponents(); + + const f = TestBed.createComponent(ClarinWayfComponent); + f.detectChanges(); + + const params = f.componentInstance.samldsParams(); + expect(params.isPassive).toBeTrue(); + expect(params.return).toBe('https://sp.example.org/return'); + expect(localStorage.getItem('wayf:last-idp')).toBe('https://idp.example.org/shibboleth'); + }); + }); +}); diff --git a/src/app/clarin-wayf/clarin-wayf.component.ts b/src/app/clarin-wayf/clarin-wayf.component.ts index 8fe3e7583ef..a565f0dd885 100644 --- a/src/app/clarin-wayf/clarin-wayf.component.ts +++ b/src/app/clarin-wayf/clarin-wayf.component.ts @@ -7,9 +7,11 @@ import { input, OnInit, output, + PLATFORM_ID, signal, viewChild, } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { ActivatedRoute } from '@angular/router'; import { IdentityProvider } from './models/idp-entry.model'; @@ -44,6 +46,12 @@ import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-id WayfIdpListComponent, WayfRecentIdpsComponent, ], + providers: [ + WayfFeedService, + WayfI18nService, + WayfPersistenceService, + WayfSearchService, + ], template: `
@if (resolvedServiceName()) { @@ -101,6 +109,17 @@ import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-id (focusSearch)="onFocusSearch()" /> + + @if (displayEntries().length < filteredEntries().length) { +
+ +
+ } + @if (resolvedLocalAuthEnabled()) {
@@ -139,6 +158,7 @@ export class ClarinWayfComponent implements OnInit { private readonly searchService = inject(WayfSearchService); private readonly route = inject(ActivatedRoute); private readonly wayfConfig = inject(WAYF_CONFIG); + private readonly platformId = inject(PLATFORM_ID); // ── Required inputs ────────────────────────────────────────── @@ -196,7 +216,8 @@ export class ClarinWayfComponent implements OnInit { // ── Internal state ─────────────────────────────────────────── readonly searchQuery = signal(''); - + /** Current display cap — grows by pageSize on each "Show more" click. */ + readonly displayLimit = signal(0); /** SAMLDS params parsed from the URL. */ readonly samldsParams = signal({ entityID: null, @@ -207,12 +228,17 @@ export class ClarinWayfComponent implements OnInit { // ── Resolved config (input → WAYF_CONFIG → default) ───────── + /** + * Resolve a config value with priority: input → injected token → built-in default. + * Empty string and undefined are treated as "not set" for inputs. + */ private resolve( - inputValue: any, + inputValue: WayfConfig[K] | '' | undefined, key: K, - ) { - if (inputValue !== '' && inputValue !== undefined) { return inputValue; } - return (this.wayfConfig as any)[key] ?? (WAYF_DEFAULTS as any)[key]; + ): WayfConfig[K] { + if (inputValue !== '' && inputValue !== undefined) { return inputValue as WayfConfig[K]; } + if (key in this.wayfConfig) { return this.wayfConfig[key]; } + return WAYF_DEFAULTS[key as keyof typeof WAYF_DEFAULTS] as WayfConfig[K]; } readonly resolvedServiceName = computed(() => this.resolve(this.serviceName(), 'serviceName')); @@ -220,33 +246,32 @@ export class ClarinWayfComponent implements OnInit { readonly resolvedEnableSearch = computed(() => this.resolve(this.enableSearch(), 'enableSearch')); readonly resolvedLocalAuthEnabled = computed(() => this.resolve(this.localAuthEnabled(), 'localAuthEnabled')); readonly resolvedHelpText = computed(() => this.resolve(this.helpText(), 'helpText')); - readonly resolvedMaxResults = computed(() => this.resolve(this.maxResults(), 'maxResults') as number); - readonly resolvedLocale = computed(() => this.resolve(this.locale(), 'locale') as string); + readonly resolvedMaxResults = computed(() => this.resolve(this.maxResults(), 'maxResults')); + readonly resolvedLocale = computed(() => this.resolve(this.locale(), 'locale')); - /** Set of pinned IdP entityIDs for badge display. */ - readonly pinnedEntityIdSet = computed(() => { - const pinned = this.pinnedIdps().length > 0 - ? this.pinnedIdps() - : (this.wayfConfig as any).pinnedIdps ?? []; - return new Set(pinned.map((p: IdentityProvider) => p.entityID)); + /** Resolved pinned IdPs: from input first, then from injected config. */ + private readonly resolvedPinnedIdps = computed(() => { + const fromInput = this.pinnedIdps(); + return fromInput.length > 0 ? fromInput : this.wayfConfig.pinnedIdps ?? []; }); + /** Set of pinned IdP entityIDs for badge display. */ + readonly pinnedEntityIdSet = computed(() => + new Set(this.resolvedPinnedIdps().map(p => p.entityID)), + ); + /** EntityID of the first pinned IdP (for the shortcut card). */ readonly pinnedEntityId = computed(() => { - const pinned = this.pinnedIdps().length > 0 - ? this.pinnedIdps() - : (this.wayfConfig as any).pinnedIdps ?? []; + const pinned = this.resolvedPinnedIdps(); return pinned.length > 0 ? pinned[0].entityID : null; }); /** All entries: feed entries + pinned entries (deduplicated). */ readonly allDisplayEntries = computed(() => { const feed = this.feedService.entries(); - const pinned = this.pinnedIdps().length > 0 - ? this.pinnedIdps() - : (this.wayfConfig as any).pinnedIdps ?? []; + const pinned = this.resolvedPinnedIdps(); const feedIds = new Set(feed.map(e => e.entityID)); - const extra = pinned.filter((p: IdentityProvider) => !feedIds.has(p.entityID)); + const extra = pinned.filter(p => !feedIds.has(p.entityID)); return [...extra, ...feed]; }); @@ -258,16 +283,21 @@ export class ClarinWayfComponent implements OnInit { ), ); - /** Final display order with maxResults limit. */ + /** Visible entries, capped at displayLimit. */ readonly displayEntries = computed(() => { const filtered = this.filteredEntries(); - const max = this.resolvedMaxResults(); - return max > 0 ? filtered.slice(0, max) : filtered; + const limit = this.displayLimit(); + return limit > 0 ? filtered.slice(0, limit) : filtered; }); private readonly searchBar = viewChild(WayfSearchBarComponent); private readonly idpList = viewChild(WayfIdpListComponent); + private get pageSize(): number { + const m = this.resolvedMaxResults(); + return m > 0 ? m : 25; + } + constructor() { // Sync locale to i18n service effect(() => { @@ -279,15 +309,21 @@ export class ClarinWayfComponent implements OnInit { } ngOnInit(): void { + this.displayLimit.set(this.pageSize); this.parseSamldsParams(); this.loadFeed(); } onQueryChange(query: string): void { this.searchQuery.set(query); + this.displayLimit.set(this.pageSize); this.idpList()?.resetActive(); } + showMore(): void { + this.displayLimit.update(n => n + this.pageSize); + } + onIdpSelected(entry: IdentityProvider): void { const remember = this.resolve(this.rememberSelection(), 'rememberSelection'); if (remember) { @@ -297,7 +333,7 @@ export class ClarinWayfComponent implements OnInit { this.idpSelected.emit(entry); const params = this.samldsParams(); - if (params.return) { + if (params.return && isPlatformBrowser(this.platformId)) { const separator = params.return.includes('?') ? '&' : '?'; const redirectUrl = `${params.return}${separator}${encodeURIComponent(params.returnIDParam)}=${encodeURIComponent(entry.entityID)}`; window.location.href = redirectUrl; @@ -318,14 +354,15 @@ export class ClarinWayfComponent implements OnInit { private parseSamldsParams(): void { const queryParams = this.route.snapshot.queryParams; + const rawReturn = queryParams['return'] ?? null; this.samldsParams.set({ entityID: queryParams['entityID'] ?? null, - return: queryParams['return'] ?? null, + return: this.sanitizeReturnUrl(rawReturn), returnIDParam: queryParams['returnIDParam'] ?? 'entityID', isPassive: queryParams['isPassive'] === 'true', }); - if (this.samldsParams().isPassive) { + if (this.samldsParams().isPassive && isPlatformBrowser(this.platformId)) { const lastIdp = this.persistence.lastIdp(); if (lastIdp && this.samldsParams().return) { const params = this.samldsParams(); @@ -336,9 +373,35 @@ export class ClarinWayfComponent implements OnInit { } } + /** + * Validate a SAMLDS return URL to prevent open-redirect attacks. + * Only allows http: and https: schemes; rejects everything else. + */ + private sanitizeReturnUrl(url: string | null): string | null { + if (!url) { return null; } + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + return url; + } + } catch { + // Malformed URL — fall through to reject + } + return null; + } + private loadFeed(): void { const url = this.resolve(this.feedUrl(), 'feedUrl'); if (!url) { return; } + // Reject non-HTTP(S) feed URLs (e.g. javascript:, data:) + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return; + } + } catch { + return; + } const loc = this.resolvedLocale(); this.feedService.loadFeed(url, loc); } diff --git a/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts new file mode 100644 index 00000000000..53ff39dbf15 --- /dev/null +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts @@ -0,0 +1,103 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfIdpCardComponent } from './wayf-idp-card.component'; +import { WayfI18nService } from '../../services/i18n.service'; +import { IdentityProvider } from '../../models/idp-entry.model'; + +describe('WayfIdpCardComponent', () => { + let component: WayfIdpCardComponent; + let fixture: ComponentFixture; + + const mockEntry: IdentityProvider = { + entityID: 'https://idp.example.org', + title: 'Example University', + logoUrl: 'https://example.org/logo.png', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WayfIdpCardComponent], + providers: [WayfI18nService], + }).compileComponents(); + + fixture = TestBed.createComponent(WayfIdpCardComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('entry', mockEntry); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the IdP title', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.wayf-idp-card__name')?.textContent?.trim()).toBe('Example University'); + }); + + it('should display the entityID', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.wayf-idp-card__entity-id')?.textContent?.trim()).toBe('https://idp.example.org'); + }); + + it('should show logo image when logoUrl is provided', () => { + const img = fixture.nativeElement.querySelector('img'); + expect(img).toBeTruthy(); + expect(img.src).toContain('logo.png'); + }); + + it('should show placeholder icon when no logoUrl', () => { + fixture.componentRef.setInput('entry', { entityID: 'e1', title: 'No Logo' }); + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('.fas.fa-university'); + expect(icon).toBeTruthy(); + expect(fixture.nativeElement.querySelector('img')).toBeNull(); + }); + + it('should show placeholder icon when logo fails to load', () => { + const img: HTMLImageElement = fixture.nativeElement.querySelector('img'); + expect(img).toBeTruthy(); + + // Simulate image error + img.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.fas.fa-university')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('img')).toBeNull(); + }); + + it('should emit selected event on click', () => { + const spy = jasmine.createSpy('selected'); + component.selected.subscribe(spy); + + const card = fixture.nativeElement.querySelector('.wayf-idp-card'); + card.click(); + + expect(spy).toHaveBeenCalledWith(mockEntry); + }); + + it('should show hub badge when isHub is true', () => { + fixture.componentRef.setInput('isHub', true); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.badge'); + expect(badge).toBeTruthy(); + }); + + it('should not show hub badge when isHub is false', () => { + fixture.componentRef.setInput('isHub', false); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.badge'); + expect(badge).toBeNull(); + }); + + it('should apply active class when isActive is true', () => { + fixture.componentRef.setInput('isActive', true); + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('.wayf-idp-card'); + expect(card.classList).toContain('wayf-idp-card--active'); + }); +}); diff --git a/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts new file mode 100644 index 00000000000..adb3423f76b --- /dev/null +++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts @@ -0,0 +1,115 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfIdpListComponent } from './wayf-idp-list.component'; +import { WayfI18nService } from '../../services/i18n.service'; +import { IdentityProvider } from '../../models/idp-entry.model'; + +describe('WayfIdpListComponent', () => { + let component: WayfIdpListComponent; + let fixture: ComponentFixture; + + const entries: IdentityProvider[] = [ + { entityID: 'https://a.example.org', title: 'Alpha University' }, + { entityID: 'https://b.example.org', title: 'Beta University' }, + { entityID: 'https://c.example.org', title: 'Charlie University' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WayfIdpListComponent], + providers: [WayfI18nService], + }).compileComponents(); + + fixture = TestBed.createComponent(WayfIdpListComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('entries', entries); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render one card per entry', () => { + const cards = fixture.nativeElement.querySelectorAll('ds-wayf-idp-card'); + expect(cards.length).toBe(3); + }); + + it('should show "no results" when entries is empty', () => { + fixture.componentRef.setInput('entries', []); + fixture.detectChanges(); + + const empty = fixture.nativeElement.querySelector('.wayf-idp-list__empty'); + expect(empty).toBeTruthy(); + }); + + // ── Keyboard navigation ───────────────────────────────────── + + describe('onKeydown()', () => { + it('should move activeIndex down on ArrowDown', () => { + component.activeIndex.set(-1); + component.onKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(component.activeIndex()).toBe(0); + }); + + it('should not go past last entry on ArrowDown', () => { + component.activeIndex.set(2); + component.onKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(component.activeIndex()).toBe(2); + }); + + it('should move activeIndex up on ArrowUp', () => { + component.activeIndex.set(2); + component.onKeydown(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(component.activeIndex()).toBe(1); + }); + + it('should emit focusSearch when ArrowUp at index 0', () => { + const spy = jasmine.createSpy('focusSearch'); + component.focusSearch.subscribe(spy); + + component.activeIndex.set(0); + component.onKeydown(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + + expect(spy).toHaveBeenCalled(); + expect(component.activeIndex()).toBe(-1); + }); + + it('should emit idpSelected on Enter when an item is active', () => { + const spy = jasmine.createSpy('idpSelected'); + component.idpSelected.subscribe(spy); + + component.activeIndex.set(1); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(spy).toHaveBeenCalledWith(entries[1]); + }); + + it('should not emit idpSelected on Enter when no item is active', () => { + const spy = jasmine.createSpy('idpSelected'); + component.idpSelected.subscribe(spy); + + component.activeIndex.set(-1); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should emit focusSearch on Escape', () => { + const spy = jasmine.createSpy('focusSearch'); + component.focusSearch.subscribe(spy); + + component.onKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('resetActive()', () => { + it('should reset activeIndex to -1', () => { + component.activeIndex.set(2); + component.resetActive(); + expect(component.activeIndex()).toBe(-1); + }); + }); +}); diff --git a/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts new file mode 100644 index 00000000000..e49a4d088bf --- /dev/null +++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfRecentIdpsComponent } from './wayf-recent-idps.component'; +import { WayfI18nService } from '../../services/i18n.service'; +import { IdentityProvider } from '../../models/idp-entry.model'; + +describe('WayfRecentIdpsComponent', () => { + let component: WayfRecentIdpsComponent; + let fixture: ComponentFixture; + + const allEntries: IdentityProvider[] = [ + { entityID: 'https://a.example.org', title: 'Alpha University' }, + { entityID: 'https://b.example.org', title: 'Beta University' }, + { entityID: 'https://default.example.org', title: 'Default University' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WayfRecentIdpsComponent], + providers: [WayfI18nService], + }).compileComponents(); + + fixture = TestBed.createComponent(WayfRecentIdpsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('allEntries', allEntries); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render nothing when no default or last-used IdP', () => { + expect(component.shortcutEntry()).toBeNull(); + expect(fixture.nativeElement.querySelector('.wayf-shortcut')).toBeNull(); + }); + + describe('with defaultEntityId', () => { + beforeEach(() => { + fixture.componentRef.setInput('defaultEntityId', 'https://default.example.org'); + fixture.detectChanges(); + }); + + it('should show the default IdP as shortcut', () => { + expect(component.shortcutEntry()?.entityID).toBe('https://default.example.org'); + }); + + it('should display the correct label for static default', () => { + const el = fixture.nativeElement as HTMLElement; + const label = el.querySelector('.small.text-muted')?.textContent?.trim(); + expect(label).toBe('Default institution'); + }); + + it('should display the IdP name', () => { + expect(component.shortcutDisplayName()).toBe('Default University'); + }); + }); + + describe('with lastIdpEntityId', () => { + beforeEach(() => { + fixture.componentRef.setInput('lastIdpEntityId', 'https://a.example.org'); + fixture.detectChanges(); + }); + + it('should show the last-used IdP as shortcut', () => { + expect(component.shortcutEntry()?.entityID).toBe('https://a.example.org'); + }); + + it('should display "Continue with" label', () => { + const el = fixture.nativeElement as HTMLElement; + const label = el.querySelector('.small.text-muted')?.textContent?.trim(); + expect(label).toBe('Continue with'); + }); + }); + + describe('priority: default over last-used', () => { + it('should prefer defaultEntityId over lastIdpEntityId', () => { + fixture.componentRef.setInput('defaultEntityId', 'https://default.example.org'); + fixture.componentRef.setInput('lastIdpEntityId', 'https://a.example.org'); + fixture.detectChanges(); + + expect(component.shortcutEntry()?.entityID).toBe('https://default.example.org'); + }); + }); + + describe('unknown IDs', () => { + it('should render nothing for unknown defaultEntityId', () => { + fixture.componentRef.setInput('defaultEntityId', 'https://unknown.example.org'); + fixture.detectChanges(); + + expect(component.shortcutEntry()).toBeNull(); + }); + + it('should render nothing for unknown lastIdpEntityId', () => { + fixture.componentRef.setInput('lastIdpEntityId', 'https://unknown.example.org'); + fixture.detectChanges(); + + expect(component.shortcutEntry()).toBeNull(); + }); + }); + + describe('events', () => { + it('should emit idpSelected on click', () => { + fixture.componentRef.setInput('defaultEntityId', 'https://default.example.org'); + fixture.detectChanges(); + + const spy = jasmine.createSpy('idpSelected'); + component.idpSelected.subscribe(spy); + + const card = fixture.nativeElement.querySelector('.wayf-shortcut__card'); + card.click(); + + expect(spy).toHaveBeenCalledWith(allEntries[2]); + }); + }); +}); diff --git a/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts new file mode 100644 index 00000000000..ac6d56eb791 --- /dev/null +++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfSearchBarComponent } from './wayf-search-bar.component'; +import { WayfI18nService } from '../../services/i18n.service'; + +describe('WayfSearchBarComponent', () => { + let component: WayfSearchBarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WayfSearchBarComponent], + providers: [WayfI18nService], + }).compileComponents(); + + fixture = TestBed.createComponent(WayfSearchBarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render an input element', () => { + const input = fixture.nativeElement.querySelector('input[type="search"]'); + expect(input).toBeTruthy(); + }); + + it('should have a label for accessibility', () => { + const label = fixture.nativeElement.querySelector('label'); + expect(label).toBeTruthy(); + expect(label.getAttribute('for')).toBe('wayf-search-input'); + }); + + it('should emit queryChange on input', () => { + const spy = jasmine.createSpy('queryChange'); + component.queryChange.subscribe(spy); + + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + input.value = 'test'; + input.dispatchEvent(new Event('input')); + + expect(spy).toHaveBeenCalledWith('test'); + }); + + it('should have role="combobox" on input', () => { + const input = fixture.nativeElement.querySelector('input'); + expect(input.getAttribute('role')).toBe('combobox'); + }); + + it('should set aria-expanded based on hasResults', () => { + fixture.componentRef.setInput('hasResults', true); + fixture.detectChanges(); + + const input = fixture.nativeElement.querySelector('input'); + expect(input.getAttribute('aria-expanded')).toBe('true'); + }); + + describe('focusInput()', () => { + it('should focus the search input', () => { + const input: HTMLInputElement = fixture.nativeElement.querySelector('input'); + spyOn(input, 'focus'); + component.focusInput(); + expect(input.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/clarin-wayf/index.ts b/src/app/clarin-wayf/index.ts new file mode 100644 index 00000000000..39f6f7286fd --- /dev/null +++ b/src/app/clarin-wayf/index.ts @@ -0,0 +1,19 @@ +/** + * Public API surface for the WAYF component library. + */ + +// Configuration +export { WayfConfig, WAYF_CONFIG, WAYF_DEFAULTS, SamldsParams } from './wayf.config'; +export { WayfModule } from './wayf.module'; + +// Main component +export { ClarinWayfComponent } from './clarin-wayf.component'; + +// Models +export { IdentityProvider, DiscoFeedEntry, DiscoFeedLocalizedValue, DiscoFeedLogoEntry } from './models/idp-entry.model'; + +// Services (for advanced use / testing) +export { WayfFeedService } from './services/feed.service'; +export { WayfI18nService } from './services/i18n.service'; +export { WayfPersistenceService } from './services/persistence.service'; +export { WayfSearchService } from './services/search.service'; diff --git a/src/app/clarin-wayf/models/idp-entry.model.spec.ts b/src/app/clarin-wayf/models/idp-entry.model.spec.ts new file mode 100644 index 00000000000..24a715b19a4 --- /dev/null +++ b/src/app/clarin-wayf/models/idp-entry.model.spec.ts @@ -0,0 +1,117 @@ +import { + DiscoFeedEntry, + normalizeDiscoFeedEntry, + normalizeEntry, + resolveLocalized, +} from './idp-entry.model'; + +describe('idp-entry.model', () => { + // ── resolveLocalized() ──────────────────────────────────────── + + describe('resolveLocalized()', () => { + it('should return exact lang match', () => { + const values = [ + { value: 'Czech Name', lang: 'cs' }, + { value: 'English Name', lang: 'en' }, + ]; + expect(resolveLocalized(values, 'cs')).toBe('Czech Name'); + }); + + it('should fall back to en when lang not found', () => { + const values = [ + { value: 'French Name', lang: 'fr' }, + { value: 'English Name', lang: 'en' }, + ]; + expect(resolveLocalized(values, 'de')).toBe('English Name'); + }); + + it('should fall back to first entry when neither lang nor en found', () => { + const values = [ + { value: 'French Name', lang: 'fr' }, + ]; + expect(resolveLocalized(values, 'de')).toBe('French Name'); + }); + + it('should return fallback for undefined array', () => { + expect(resolveLocalized(undefined, 'en', 'fallback')).toBe('fallback'); + }); + + it('should return fallback for empty array', () => { + expect(resolveLocalized([], 'en', 'fallback')).toBe('fallback'); + }); + + it('should return empty string as default fallback', () => { + expect(resolveLocalized(undefined, 'en')).toBe(''); + }); + }); + + // ── normalizeDiscoFeedEntry() ───────────────────────────────── + + describe('normalizeDiscoFeedEntry()', () => { + it('should normalize a DiscoFeed entry', () => { + const raw: DiscoFeedEntry = { + entityID: 'https://idp.example.org', + DisplayNames: [{ value: 'Test University', lang: 'en' }], + Logos: [{ value: 'https://example.org/logo.png', height: 40 }], + Keywords: [{ value: 'test' }, { value: 'university' }], + }; + + const result = normalizeDiscoFeedEntry(raw, 'en'); + + expect(result.entityID).toBe('https://idp.example.org'); + expect(result.title).toBe('Test University'); + expect(result.logoUrl).toBe('https://example.org/logo.png'); + expect(result.keywords).toEqual(['test', 'university']); + }); + + it('should use entityID as title fallback when no DisplayNames', () => { + const raw: DiscoFeedEntry = { + entityID: 'https://idp.example.org', + }; + const result = normalizeDiscoFeedEntry(raw, 'en'); + expect(result.title).toBe('https://idp.example.org'); + }); + + it('should prefer small logos (height <= 60)', () => { + const raw: DiscoFeedEntry = { + entityID: 'e1', + Logos: [ + { value: 'https://example.org/big.png', height: 100 }, + { value: 'https://example.org/small.png', height: 40 }, + ], + }; + const result = normalizeDiscoFeedEntry(raw, 'en'); + expect(result.logoUrl).toBe('https://example.org/small.png'); + }); + }); + + // ── normalizeEntry() ───────────────────────────────────────── + + describe('normalizeEntry()', () => { + it('should detect and normalize DiscoFeed entries', () => { + const raw = { + entityID: 'e1', + DisplayNames: [{ value: 'Disco Entry', lang: 'en' }], + }; + const result = normalizeEntry(raw, 'en'); + expect(result.title).toBe('Disco Entry'); + }); + + it('should pass through IdentityProvider-like entries', () => { + const raw = { + entityID: 'e1', + title: 'Already Normalized', + country: 'CZ', + }; + const result = normalizeEntry(raw, 'en'); + expect(result.title).toBe('Already Normalized'); + expect(result.country).toBe('CZ'); + }); + + it('should use entityID as title fallback for flat entries', () => { + const raw = { entityID: 'e1' }; + const result = normalizeEntry(raw, 'en'); + expect(result.title).toBe('e1'); + }); + }); +}); diff --git a/src/app/clarin-wayf/services/feed.service.spec.ts b/src/app/clarin-wayf/services/feed.service.spec.ts new file mode 100644 index 00000000000..8adbc503809 --- /dev/null +++ b/src/app/clarin-wayf/services/feed.service.spec.ts @@ -0,0 +1,169 @@ +import { TestBed } from '@angular/core/testing'; +import { PLATFORM_ID } from '@angular/core'; + +import { WayfFeedService } from './feed.service'; +import { IdentityProvider } from '../models/idp-entry.model'; + +describe('WayfFeedService', () => { + let service: WayfFeedService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + WayfFeedService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + service = TestBed.inject(WayfFeedService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should start with empty entries', () => { + expect(service.entries()).toEqual([]); + }); + + it('should start with loading = false', () => { + expect(service.loading()).toBe(false); + }); + + it('should start with error = null', () => { + expect(service.error()).toBeNull(); + }); + + describe('loadFeed()', () => { + let fetchSpy: jasmine.Spy; + + afterEach(() => { + fetchSpy?.and.callThrough(); + }); + + it('should parse a standard DiscoFeed response', async () => { + const mockData = [ + { + entityID: 'https://idp.example.org', + DisplayNames: [{ value: 'Example Uni', lang: 'en' }], + }, + ]; + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(JSON.stringify(mockData), { status: 200 }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.entries().length).toBe(1); + expect(service.entries()[0].entityID).toBe('https://idp.example.org'); + expect(service.entries()[0].title).toBe('Example Uni'); + expect(service.loading()).toBe(false); + expect(service.error()).toBeNull(); + }); + + it('should parse a flat IdentityProvider response', async () => { + const mockData: IdentityProvider[] = [ + { entityID: 'https://idp.example.org', title: 'Example Uni' }, + ]; + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(JSON.stringify(mockData), { status: 200 }), + ); + + await service.loadFeed('https://feed.example.org/feed.json'); + + expect(service.entries().length).toBe(1); + expect(service.entries()[0].title).toBe('Example Uni'); + }); + + it('should set error on HTTP failure', async () => { + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(null, { status: 500, statusText: 'Internal Server Error' }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.error()).toContain('500'); + expect(service.entries()).toEqual([]); + expect(service.loading()).toBe(false); + }); + + it('should set error on network failure', async () => { + fetchSpy = spyOn(globalThis, 'fetch').and.rejectWith(new Error('Network error')); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.error()).toBe('Network error'); + expect(service.entries()).toEqual([]); + }); + + it('should handle non-array JSON gracefully', async () => { + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(JSON.stringify({ not: 'an array' }), { status: 200 }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.entries()).toEqual([]); + expect(service.error()).toBeNull(); + }); + + it('should handle HTTP 204 (no content)', async () => { + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(null, { status: 204, statusText: 'No Content' }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.entries()).toEqual([]); + expect(service.error()).toBeNull(); + }); + + it('should set loading to true during fetch', async () => { + let resolvePromise: (value: Response) => void; + const pendingResponse = new Promise(resolve => { + resolvePromise = resolve; + }); + fetchSpy = spyOn(globalThis, 'fetch').and.returnValue(pendingResponse); + + const loadPromise = service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.loading()).toBe(true); + + resolvePromise!(new Response(JSON.stringify([]), { status: 200 })); + await loadPromise; + + expect(service.loading()).toBe(false); + }); + + it('should call fetch with credentials: omit', async () => { + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(JSON.stringify([]), { status: 200 }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://feed.example.org/DiscoFeed', + { credentials: 'omit' }, + ); + }); + }); + + describe('SSR safety', () => { + it('should skip fetch on server platform', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + WayfFeedService, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + const ssrService = TestBed.inject(WayfFeedService); + const fetchSpy = spyOn(globalThis, 'fetch'); + + await ssrService.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(ssrService.entries()).toEqual([]); + }); + }); +}); diff --git a/src/app/clarin-wayf/services/feed.service.ts b/src/app/clarin-wayf/services/feed.service.ts index a10f0a58463..336a0ec5658 100644 --- a/src/app/clarin-wayf/services/feed.service.ts +++ b/src/app/clarin-wayf/services/feed.service.ts @@ -20,7 +20,7 @@ import { IdentityProvider, normalizeEntry } from '../models/idp-entry.model'; * format (with `title`, `logoUrl`, etc.). Entries are auto-detected and * normalized to `IdentityProvider` on load. */ -@Injectable({ providedIn: 'root' }) +@Injectable() export class WayfFeedService { private readonly platformId = inject(PLATFORM_ID); @@ -57,6 +57,12 @@ export class WayfFeedService { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } + // HTTP 204 No Content — valid but empty + if (response.status === 204) { + this.entries.set([]); + return; + } + const data: any[] = await response.json(); if (!Array.isArray(data)) { @@ -67,6 +73,7 @@ export class WayfFeedService { this.entries.set(data.map(raw => normalizeEntry(raw, locale))); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to load IdP feed'; + console.warn('[WAYF] Feed load failed:', message); this.error.set(message); this.entries.set([]); } finally { diff --git a/src/app/clarin-wayf/services/i18n.service.spec.ts b/src/app/clarin-wayf/services/i18n.service.spec.ts new file mode 100644 index 00000000000..f7cce4ad29e --- /dev/null +++ b/src/app/clarin-wayf/services/i18n.service.spec.ts @@ -0,0 +1,114 @@ +import { TestBed } from '@angular/core/testing'; + +import { WayfI18nService } from './i18n.service'; + +describe('WayfI18nService', () => { + let service: WayfI18nService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [WayfI18nService], + }); + service = TestBed.inject(WayfI18nService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + // ── t() ───────────────────────────────────────────────────── + + describe('t()', () => { + it('should return English translation by default', () => { + service.setLang('en'); + expect(service.t('wayf.title')).toBe('Select your institution'); + }); + + it('should return Czech translation when lang is cs', () => { + service.setLang('cs'); + expect(service.t('wayf.title')).toBe('Vyberte svou instituci'); + }); + + it('should return German translation when lang is de', () => { + service.setLang('de'); + expect(service.t('wayf.title')).toBe('Wählen Sie Ihre Einrichtung'); + }); + + it('should fall back to English for unsupported language', () => { + service.setLang('xx'); + expect(service.t('wayf.title')).toBe('Select your institution'); + }); + + it('should return the key when translation is missing', () => { + service.setLang('en'); + expect(service.t('wayf.nonexistent.key')).toBe('wayf.nonexistent.key'); + }); + + it('should interpolate {count} placeholder', () => { + service.setLang('en'); + expect(service.t('wayf.search.results', { count: 5 })).toBe('5 institutions found'); + }); + + it('should interpolate multiple occurrences of the same placeholder', () => { + service.setLang('en'); + // wayf.a11y.result-count uses {count} + expect(service.t('wayf.a11y.result-count', { count: 12 })).toBe('12 results available'); + }); + }); + + // ── setLang() ─────────────────────────────────────────────── + + describe('setLang()', () => { + it('should change the active language', () => { + service.setLang('cs'); + expect(service.lang()).toBe('cs'); + }); + + it('should update translations reactively', () => { + service.setLang('en'); + const enTitle = service.t('wayf.loading'); + service.setLang('de'); + const deTitle = service.t('wayf.loading'); + expect(enTitle).not.toBe(deTitle); + }); + }); + + // ── Missing key coverage ──────────────────────────────────── + + describe('wayf.local-auth key', () => { + it('should have English translation for wayf.local-auth', () => { + service.setLang('en'); + expect(service.t('wayf.local-auth')).toBe('Log in with local account'); + }); + + it('should have Czech translation for wayf.local-auth', () => { + service.setLang('cs'); + expect(service.t('wayf.local-auth')).not.toBe('wayf.local-auth'); + }); + + it('should have German translation for wayf.local-auth', () => { + service.setLang('de'); + expect(service.t('wayf.local-auth')).not.toBe('wayf.local-auth'); + }); + }); + + // ── All keys consistency ──────────────────────────────────── + + describe('translation key consistency', () => { + it('should have the same keys in cs as in en', () => { + service.setLang('en'); + const enKeys = Object.keys(service.translations()); + service.setLang('cs'); + const csKeys = Object.keys(service.translations()); + expect(csKeys.sort()).toEqual(enKeys.sort()); + }); + + it('should have the same keys in de as in en', () => { + service.setLang('en'); + const enKeys = Object.keys(service.translations()); + service.setLang('de'); + const deKeys = Object.keys(service.translations()); + expect(deKeys.sort()).toEqual(enKeys.sort()); + }); + }); +}); diff --git a/src/app/clarin-wayf/services/i18n.service.ts b/src/app/clarin-wayf/services/i18n.service.ts index a340a997f45..2eccc0e4643 100644 --- a/src/app/clarin-wayf/services/i18n.service.ts +++ b/src/app/clarin-wayf/services/i18n.service.ts @@ -23,6 +23,8 @@ const TRANSLATIONS: Record> = { 'wayf.a11y.list-label': 'List of identity providers', 'wayf.a11y.result-count': '{count} results available', 'wayf.pinned.label': 'Default institution', + 'wayf.local-auth': 'Log in with local account', + 'wayf.show-more': 'Show more', }, cs: { 'wayf.title': 'Vyberte svou instituci', @@ -37,6 +39,8 @@ const TRANSLATIONS: Record> = { 'wayf.a11y.list-label': 'Seznam poskytovatelů identity', 'wayf.a11y.result-count': '{count} výsledků k dispozici', 'wayf.pinned.label': 'Výchozí instituce', + 'wayf.local-auth': 'Přihlásit se místním účtem', + 'wayf.show-more': 'Zobrazit další', }, de: { 'wayf.title': 'Wählen Sie Ihre Einrichtung', @@ -51,6 +55,8 @@ const TRANSLATIONS: Record> = { 'wayf.a11y.list-label': 'Liste der Identitätsanbieter', 'wayf.a11y.result-count': '{count} Ergebnisse verfügbar', 'wayf.pinned.label': 'Standardeinrichtung', + 'wayf.local-auth': 'Mit lokalem Konto anmelden', + 'wayf.show-more': 'Mehr anzeigen', }, }; @@ -58,7 +64,7 @@ const TRANSLATIONS: Record> = { * Signal-based translation service for the WAYF component. * Self-contained — no dependency on @ngx-translate or Angular i18n compiler. */ -@Injectable({ providedIn: 'root' }) +@Injectable() export class WayfI18nService { /** Active language code. */ diff --git a/src/app/clarin-wayf/services/persistence.service.spec.ts b/src/app/clarin-wayf/services/persistence.service.spec.ts new file mode 100644 index 00000000000..cb195f837b9 --- /dev/null +++ b/src/app/clarin-wayf/services/persistence.service.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { PLATFORM_ID } from '@angular/core'; + +import { WayfPersistenceService } from './persistence.service'; + +describe('WayfPersistenceService', () => { + let service: WayfPersistenceService; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + WayfPersistenceService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + service = TestBed.inject(WayfPersistenceService); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should start with null lastIdp when localStorage is empty', () => { + expect(service.lastIdp()).toBeNull(); + }); + + describe('selectIdp()', () => { + it('should update the lastIdp signal', () => { + service.selectIdp('https://idp.example.org'); + expect(service.lastIdp()).toBe('https://idp.example.org'); + }); + + it('should persist the selection to localStorage', () => { + service.selectIdp('https://idp.example.org'); + expect(localStorage.getItem('wayf:last-idp')).toBe('https://idp.example.org'); + }); + + it('should overwrite previous selection', () => { + service.selectIdp('https://first.example.org'); + service.selectIdp('https://second.example.org'); + expect(service.lastIdp()).toBe('https://second.example.org'); + expect(localStorage.getItem('wayf:last-idp')).toBe('https://second.example.org'); + }); + }); + + describe('initialization from localStorage', () => { + it('should read existing value from localStorage on creation', () => { + localStorage.setItem('wayf:last-idp', 'https://persisted.example.org'); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + WayfPersistenceService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + const freshService = TestBed.inject(WayfPersistenceService); + + expect(freshService.lastIdp()).toBe('https://persisted.example.org'); + }); + }); + + describe('SSR safety', () => { + it('should return null lastIdp on server platform', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + WayfPersistenceService, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + const ssrService = TestBed.inject(WayfPersistenceService); + + expect(ssrService.lastIdp()).toBeNull(); + }); + + it('should not throw on selectIdp() in server platform', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + WayfPersistenceService, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + const ssrService = TestBed.inject(WayfPersistenceService); + + expect(() => ssrService.selectIdp('https://example.org')).not.toThrow(); + expect(ssrService.lastIdp()).toBe('https://example.org'); + }); + }); +}); diff --git a/src/app/clarin-wayf/services/persistence.service.ts b/src/app/clarin-wayf/services/persistence.service.ts index 9f9f7e06ca6..a2f79b48577 100644 --- a/src/app/clarin-wayf/services/persistence.service.ts +++ b/src/app/clarin-wayf/services/persistence.service.ts @@ -1,17 +1,25 @@ import { + inject, Injectable, + PLATFORM_ID, signal, } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; -const STORAGE_KEY_LAST = 'clarin-wayf-last-idp'; +/** Default localStorage key prefix. */ +const STORAGE_KEY_PREFIX = 'wayf'; /** * Service for persisting IdP selections in localStorage. * Tracks the last selected IdP so the shortcut card can show "Continue with ...". + * + * Gracefully handles SSR (no `localStorage`) and quota-exceeded scenarios. */ -@Injectable({ providedIn: 'root' }) +@Injectable() export class WayfPersistenceService { + private readonly platformId = inject(PLATFORM_ID); + /** The entityID of the last selected IdP. */ readonly lastIdp = signal(this.readLast()); @@ -21,19 +29,26 @@ export class WayfPersistenceService { this.writeLast(entityID); } + private get storageKey(): string { + return `${STORAGE_KEY_PREFIX}:last-idp`; + } + private readLast(): string | null { + if (!isPlatformBrowser(this.platformId)) { return null; } try { - return localStorage.getItem(STORAGE_KEY_LAST); - } catch { + return localStorage.getItem(this.storageKey); + } catch (err) { + console.warn('[WAYF] Failed to read from localStorage', err); return null; } } private writeLast(entityID: string): void { + if (!isPlatformBrowser(this.platformId)) { return; } try { - localStorage.setItem(STORAGE_KEY_LAST, entityID); - } catch { - // localStorage unavailable (SSR or quota exceeded) + localStorage.setItem(this.storageKey, entityID); + } catch (err) { + console.warn('[WAYF] Failed to write to localStorage', err); } } } diff --git a/src/app/clarin-wayf/services/search.service.spec.ts b/src/app/clarin-wayf/services/search.service.spec.ts index 3009a3f777b..ac224bc55aa 100644 --- a/src/app/clarin-wayf/services/search.service.spec.ts +++ b/src/app/clarin-wayf/services/search.service.spec.ts @@ -17,7 +17,9 @@ describe('WayfSearchService', () => { let service: WayfSearchService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [WayfSearchService], + }); service = TestBed.inject(WayfSearchService); }); @@ -196,7 +198,9 @@ describe('WayfSearchService', () => { it('should match diacritics-insensitively', () => { const result = service.filterEntries(entries, 'univerzita'); - expect(result.length).toBe(2); + // All 3 match: "univerzita" appears in keywords of entries 2 & 3, + // and fuzzy/normalized scoring also matches "University" in entry 1 + expect(result.length).toBe(3); }); it('should return "University" entries for the generic term "University"', () => { diff --git a/src/app/clarin-wayf/services/search.service.ts b/src/app/clarin-wayf/services/search.service.ts index 5e36e1c1a0a..83046b4dbc8 100644 --- a/src/app/clarin-wayf/services/search.service.ts +++ b/src/app/clarin-wayf/services/search.service.ts @@ -10,7 +10,7 @@ import { IdentityProvider } from '../models/idp-entry.model'; * Handles diacritics normalization, typo tolerance via bigram similarity, * and display name resolution. */ -@Injectable({ providedIn: 'root' }) +@Injectable() export class WayfSearchService { /** Current search query. */ diff --git a/src/app/login-page/login-page.component.spec.ts b/src/app/login-page/login-page.component.spec.ts index 94416492f31..aa46237e24a 100644 --- a/src/app/login-page/login-page.component.spec.ts +++ b/src/app/login-page/login-page.component.spec.ts @@ -14,6 +14,10 @@ import { APP_DATA_SERVICES_MAP } from '../../config/app-config.interface'; import { AuthService } from '../core/auth/auth.service'; import { XSRFService } from '../core/xsrf/xsrf.service'; import { AuthServiceMock } from '../shared/mocks/auth.service.mock'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { APP_CONFIG } from '../../config/app-config.interface'; +import { WAYF_CONFIG, WAYF_DEFAULTS } from '../clarin-wayf/wayf.config'; +import { environment } from '../../environments/environment.test'; import { ActivatedRouteStub } from '../shared/testing/active-router.stub'; import { LoginPageComponent } from './login-page.component'; @@ -42,6 +46,9 @@ describe('LoginPageComponent', () => { { provide: AuthService, useValue: new AuthServiceMock() }, { provide: XSRFService, useValue: {} }, { provide: APP_DATA_SERVICES_MAP, useValue: {} }, + { provide: HardRedirectService, useValue: { redirect: jasmine.createSpy('redirect'), getCurrentRoute: jasmine.createSpy('getCurrentRoute').and.returnValue('/') } }, + { provide: APP_CONFIG, useValue: environment }, + { provide: WAYF_CONFIG, useValue: { ...WAYF_DEFAULTS, feedUrl: '', spEntityId: '', loginEndpoint: '' } }, provideMockStore({}), ], schemas: [NO_ERRORS_SCHEMA], diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 539de03b09b..f3ccd1c6445 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -33,6 +33,10 @@ import { ActivatedRouteStub } from '../testing/active-router.stub'; import { BrowserOnlyMockPipe } from '../testing/browser-only-mock.pipe'; import { EPersonMock } from '../testing/eperson.mock'; import { HostWindowServiceStub } from '../testing/host-window-service.stub'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { WAYF_CONFIG, WAYF_DEFAULTS } from '../../clarin-wayf/wayf.config'; +import { environment } from '../../../environments/environment.test'; import { AuthNavMenuComponent } from './auth-nav-menu.component'; describe('AuthNavMenuComponent', () => { @@ -104,6 +108,9 @@ describe('AuthNavMenuComponent', () => { { provide: AuthService, useValue: authService }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: XSRFService, useValue: {} }, + { provide: HardRedirectService, useValue: { redirect: jasmine.createSpy('redirect'), getCurrentRoute: jasmine.createSpy('getCurrentRoute').and.returnValue('/') } }, + { provide: APP_CONFIG, useValue: environment }, + { provide: WAYF_CONFIG, useValue: { ...WAYF_DEFAULTS, feedUrl: '', spEntityId: '', loginEndpoint: '' } }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA, @@ -296,6 +303,9 @@ describe('AuthNavMenuComponent', () => { { provide: HostWindowService, useValue: window }, { provide: AuthService, useValue: authService }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + { provide: HardRedirectService, useValue: { redirect: jasmine.createSpy('redirect'), getCurrentRoute: jasmine.createSpy('getCurrentRoute').and.returnValue('/') } }, + { provide: APP_CONFIG, useValue: environment }, + { provide: WAYF_CONFIG, useValue: { ...WAYF_DEFAULTS, feedUrl: '', spEntityId: '', loginEndpoint: '' } }, ], schemas: [ CUSTOM_ELEMENTS_SCHEMA, From d06ebccb9e1c2cc0f016cc18a080479059c33b11 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:58:33 +0200 Subject: [PATCH 07/17] changed angular.json back --- angular.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index 71478fad33b..cf348ef8d54 100644 --- a/angular.json +++ b/angular.json @@ -142,7 +142,12 @@ "polyfills": "src/polyfills.ts", "tsConfig": "tsconfig.spec.json", "karmaConfig": "karma.conf.js", - "sourceMap": true, + "sourceMap": { + "scripts": false, + "styles": false, + "hidden": false, + "vendor": false + }, "assets": [ "src/assets" ], From bb5e74eb9f5860ed75f71d8e9cdb107ea9b5ea48 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:05:47 +0200 Subject: [PATCH 08/17] New .md to describe mental model --- docs/WAYF-Mental-Model.md | 147 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/WAYF-Mental-Model.md diff --git a/docs/WAYF-Mental-Model.md b/docs/WAYF-Mental-Model.md new file mode 100644 index 00000000000..255ef26f9a9 --- /dev/null +++ b/docs/WAYF-Mental-Model.md @@ -0,0 +1,147 @@ +# 🧩 Feature Overview + +**CLARIN WAYF** (Where Are You From) is an **identity provider picker** for Shibboleth-based federated login. + +- Users see a searchable list of universities/institutions (fetched from a JSON feed) +- They pick one and get redirected to that institution's login page +- It replaces an external discovery page with an inline UI inside the DSpace login flow + +--- + +# 🗺️ High-Level Architecture + +| Part | Responsibility | +|------|---------------| +| **Orchestrator** (`ClarinWayfComponent`) | Loads IdP feed, wires search/pagination/selection, handles SAMLDS redirect protocol | +| **UI Kit** (4 sub-components) | Card, list, search bar, recent-IdP shortcut — pure presentation, no business logic | +| **Service Layer** (4 services) | Feed fetching, fuzzy search, localStorage persistence, self-contained i18n | +| **Integration Glue** (3 touch points) | Login page toggle, header dropdown tab, Shibboleth auth method button | + +--- + +# 🔄 Main Flow (Step-by-step) + +1. User opens login page → clicks **"Select your institution"** button +2. `ClarinWayfComponent` initializes → calls `WayfFeedService.loadFeed(feedUrl)` +3. Feed service fetches JSON via `fetch()`, normalizes raw entries → `IdentityProvider[]` +4. Entries stored in signal → template renders `WayfIdpListComponent` +5. User types in search bar → `searchQuery` signal updates → `filteredEntries` computed re-runs +6. `WayfSearchService.filterEntries()` scores each entry (exact > word-boundary > fuzzy bigram) +7. User clicks an IdP card → `idpSelected` event bubbles up to orchestrator +8. Orchestrator saves selection to `localStorage` (via `WayfPersistenceService`) +9. Component builds Shibboleth redirect URL with `entityID` query param +10. `window.location.href` → user lands at their institution's login page + +--- + +# 📁 File Map (Simplified) + +``` +src/app/clarin-wayf/ +├── Core Logic +│ ├── clarin-wayf.component.ts ← Main orchestrator +│ ├── wayf.config.ts ← Config interface + DI token +│ ├── wayf.module.ts ← Module wrapper (forRoot) +│ └── models/idp-entry.model.ts ← Data model + normalization +│ +├── Services (all signal-based, no external deps) +│ ├── feed.service.ts ← HTTP fetch + cache +│ ├── search.service.ts ← Fuzzy search engine +│ ├── persistence.service.ts ← localStorage wrapper +│ └── i18n.service.ts ← Built-in translations (en/cs/de) +│ +├── UI Components (pure presentation) +│ ├── components/search-bar/ ← Text input with ARIA +│ ├── components/idp-list/ ← Keyboard-navigable list +│ ├── components/idp-card/ ← Single institution card +│ └── components/recent-idps/ ← "Continue with..." shortcut +│ +└── Tests (10 spec files, 136 tests total) + +Integration points (outside the module): +├── login-page.component.ts/html ← Toggle button + collapsible panel +├── auth-nav-menu.component.ts/html ← Tab-based switch in header dropdown +└── log-in-shibboleth-wayf.component ← Auth method provider for Shibboleth backends +``` + +--- + +# ⚫ Black Box Summary + +### ClarinWayfComponent +- **Inputs:** `feedUrl`, `spEntityId`, `loginEndpoint`, `maxResults`, `locale`, `pinnedIdps`, ... +- **Outputs:** `idpSelected`, `localAuthSelected`, `cancelled` +- **Side effects:** Fetches feed URL on init, writes to `localStorage`, may redirect via `window.location.href` + +### WayfFeedService +- **Input:** `feedUrl: string`, `locale: string` +- **Output:** `entries` signal with `IdentityProvider[]` +- **Side effects:** HTTP `fetch()` call (credentials: omit) + +### WayfSearchService +- **Input:** `entries: IdentityProvider[]`, `query: string` +- **Output:** Filtered + scored `IdentityProvider[]` +- **Side effects:** None (pure functions) + +### WayfPersistenceService +- **Input:** `entityID: string` (on select) +- **Output:** `lastIdp` signal with last-used entityID +- **Side effects:** Reads/writes `localStorage` key `wayf:last-idp` + +### WayfI18nService +- **Input:** Language code (`en`, `cs`, `de`) +- **Output:** `t(key, params?)` → translated string +- **Side effects:** None + +--- + +# 🚨 Overengineered / Suspicious Code + +| Area | Concern | +|------|---------| +| **Custom i18n service** | DSpace already has `@ngx-translate`. This builds a parallel mini-i18n system (15 keys, 3 languages). Justified only if the component must work outside DSpace. | +| **Custom fuzzy search** | Sørensen–Dice bigram implementation is solid but complex (~60 lines). A simpler substring+includes filter would cover 95% of use cases. | +| **Config resolution chain** | 3-level fallback (`input → WAYF_CONFIG → WAYF_DEFAULTS`) with an `effect()` that merges them. Adds cognitive overhead for a config that rarely changes at runtime. | +| **SAMLDS protocol handling** | `parseSamldsParams()` + `sanitizeReturnUrl()` + `isPassive` auto-redirect implement a full SAMLDS client. This complexity lives in the UI component rather than a service. | +| **WayfModule.forRoot()** | The module wrapper exists for ergonomic DI setup, but the component is standalone. The module is a thin shell — could be replaced by direct `provide` calls. | +| **4 separate services** | Feed, Search, Persistence, i18n — each is small (~40-60 lines). Could arguably be 2 services (DataService = feed+persistence, SearchService stays). | + +--- + +# ✂️ Simplification Suggestions + +| Suggestion | Risk | Impact | +|------------|------|--------| +| **Replace custom i18n** with `@ngx-translate` keys in DSpace's existing `en.json5` | Low | Removes ~120 lines + 13 tests. Breaks standalone portability. | +| **Inline persistence into main component** | Low | Removes a file + 8 tests. The service is just 3 `localStorage` calls. | +| **Simplify search** to substring-only, drop Dice coefficient | Medium | Removes ~40 lines + some tests. Loses typo tolerance (e.g. "Univerzita" → "Universita"). | +| **Move SAMLDS logic to a utility function** | Low | Main component becomes cleaner. Pure function is easier to test. | +| **Drop WayfModule**, just export the component | Low | Consumers use `imports: [ClarinWayfComponent]` + `providers: [...]` directly. | +| **Merge feed.service into main component** | Medium | It's only called once. But separating it does help testability. | + +> **Safe bet:** Inline persistence + drop the module wrapper. Everything else adds real value. + +--- + +# 🧠 Mental Model Cheat Sheet + +``` +User clicks "Select institution" + → ClarinWayfComponent opens + → FeedService fetches IdP list from JSON URL + → List renders in IdpListComponent (cards) + → User types → SearchService filters (fuzzy match) + → User clicks a card + → PersistenceService saves to localStorage + → Component builds Shibboleth redirect URL + → Browser navigates to institution login +``` + +**Three places the WAYF appears:** +1. **Login page** — collapsible panel below the password form +2. **Header dropdown** — second tab ("Institution") in the auth menu +3. **Shibboleth auth method** — button in the log-in methods list (if backend has Shibboleth) + +**Key config:** 3 required URLs (`feedUrl`, `spEntityId`, `loginEndpoint`) — everything else has defaults. + +**Data flow:** `JSON feed → normalize → signal → computed (filter) → computed (paginate) → template` From 9a4e0d3e4dc0629877faee44e2d5f89948810b86 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:36:51 +0200 Subject: [PATCH 09/17] refactor: make .scss and .html files for bigger inline code --- .../clarin-wayf/clarin-wayf.component.html | 86 ++++++++++++++++++ src/app/clarin-wayf/clarin-wayf.component.ts | 89 +------------------ .../idp-card/wayf-idp-card.component.scss | 48 ++++++++++ .../idp-card/wayf-idp-card.component.ts | 44 +-------- 4 files changed, 136 insertions(+), 131 deletions(-) create mode 100644 src/app/clarin-wayf/clarin-wayf.component.html create mode 100644 src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.scss diff --git a/src/app/clarin-wayf/clarin-wayf.component.html b/src/app/clarin-wayf/clarin-wayf.component.html new file mode 100644 index 00000000000..020cf228032 --- /dev/null +++ b/src/app/clarin-wayf/clarin-wayf.component.html @@ -0,0 +1,86 @@ +
+ @if (resolvedServiceName()) { +

{{ resolvedServiceName() }}

+ } +

{{ resolvedSubtitle() }}

+ + @if (feedService.loading()) { +
+
+ {{ i18n.t('wayf.loading') }} +
+
{{ i18n.t('wayf.loading') }}
+
+ } + + @if (feedService.error()) { + + } + + @if (!feedService.loading() && !feedService.error()) { + + + + + @if (resolvedEnableSearch()) { + + + @if (searchQuery().length > 0) { +
+ {{ i18n.t('wayf.search.results', { count: filteredEntries().length }) }} +
+ } + } + + + + + + @if (displayEntries().length < filteredEntries().length) { +
+ +
+ } + + + @if (resolvedLocalAuthEnabled()) { +
+ +
+ } + + + @if (resolvedHelpText()) { +
+ {{ resolvedHelpText() }} +
+ } + } +
diff --git a/src/app/clarin-wayf/clarin-wayf.component.ts b/src/app/clarin-wayf/clarin-wayf.component.ts index a565f0dd885..8dee4bc5802 100644 --- a/src/app/clarin-wayf/clarin-wayf.component.ts +++ b/src/app/clarin-wayf/clarin-wayf.component.ts @@ -52,94 +52,7 @@ import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-id WayfPersistenceService, WayfSearchService, ], - template: ` -
- @if (resolvedServiceName()) { -

{{ resolvedServiceName() }}

- } -

{{ resolvedSubtitle() }}

- - @if (feedService.loading()) { -
-
- {{ i18n.t('wayf.loading') }} -
-
{{ i18n.t('wayf.loading') }}
-
- } - - @if (feedService.error()) { - - } - - @if (!feedService.loading() && !feedService.error()) { - - - - - @if (resolvedEnableSearch()) { - - - @if (searchQuery().length > 0) { -
- {{ i18n.t('wayf.search.results', { count: filteredEntries().length }) }} -
- } - } - - - - - - @if (displayEntries().length < filteredEntries().length) { -
- -
- } - - - @if (resolvedLocalAuthEnabled()) { -
- -
- } - - - @if (resolvedHelpText()) { -
- {{ resolvedHelpText() }} -
- } - } -
- `, + templateUrl: './clarin-wayf.component.html', styles: [` .wayf-container { max-width: 600px; diff --git a/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.scss b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.scss new file mode 100644 index 00000000000..f649f3439c2 --- /dev/null +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.scss @@ -0,0 +1,48 @@ +:host { + display: block; +} + +.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; + + &:hover, + &--active { + background-color: var(--bs-primary-bg-subtle, #e7f1ff); + border-color: var(--bs-primary, #0d6efd); + } + + &--hub { + border-left: 3px solid var(--bs-info, #0dcaf0); + } + + &__logo-box { + width: 40px; + height: 40px; + flex-shrink: 0; + } + + &__logo { + width: 40px; + height: 40px; + object-fit: contain; + display: block; + + &--placeholder { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bs-secondary-bg, #e9ecef); + border-radius: 0.25rem; + font-weight: 600; + font-size: 0.875rem; + color: var(--bs-secondary-color, #6c757d); + } + } +} + +.min-w-0 { + min-width: 0; +} 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 index cfbd1b42684..55573811eb1 100644 --- 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 @@ -51,49 +51,7 @@ import { WayfI18nService } from '../../services/i18n.service'; }
`, - styles: [` - :host { - display: block; - } - .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-box { - width: 40px; - height: 40px; - flex-shrink: 0; - } - .wayf-idp-card__logo { - width: 40px; - height: 40px; - object-fit: contain; - display: block; - } - .wayf-idp-card__logo--placeholder { - display: flex; - align-items: center; - justify-content: center; - background-color: var(--bs-secondary-bg, #e9ecef); - border-radius: 0.25rem; - font-weight: 600; - font-size: 0.875rem; - color: var(--bs-secondary-color, #6c757d); - } - .min-w-0 { - min-width: 0; - } - `], + styleUrls: ['./wayf-idp-card.component.scss'], }) export class WayfIdpCardComponent { protected readonly i18n = inject(WayfI18nService); From 8c920df985faf08edd22d213ab8de4e8e580b2f9 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:58:37 +0200 Subject: [PATCH 10/17] fix: deduplicate IdP results in feed --- .../clarin-wayf/services/feed.service.spec.ts | 17 +++++++++++++++++ src/app/clarin-wayf/services/feed.service.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/clarin-wayf/services/feed.service.spec.ts b/src/app/clarin-wayf/services/feed.service.spec.ts index 8adbc503809..8646ae2b52f 100644 --- a/src/app/clarin-wayf/services/feed.service.spec.ts +++ b/src/app/clarin-wayf/services/feed.service.spec.ts @@ -146,6 +146,23 @@ describe('WayfFeedService', () => { { credentials: 'omit' }, ); }); + + it('should deduplicate entries with the same entityID', async () => { + const mockData = [ + { entityID: 'https://idp.example.org', DisplayNames: [{ value: 'Uni A', lang: 'en' }] }, + { entityID: 'https://idp.example.org', DisplayNames: [{ value: 'Uni A', lang: 'en' }] }, + { entityID: 'https://idp2.example.org', DisplayNames: [{ value: 'Uni B', lang: 'en' }] }, + ]; + fetchSpy = spyOn(globalThis, 'fetch').and.resolveTo( + new Response(JSON.stringify(mockData), { status: 200 }), + ); + + await service.loadFeed('https://feed.example.org/DiscoFeed'); + + expect(service.entries().length).toBe(2); + expect(service.entries()[0].entityID).toBe('https://idp.example.org'); + expect(service.entries()[1].entityID).toBe('https://idp2.example.org'); + }); }); describe('SSR safety', () => { diff --git a/src/app/clarin-wayf/services/feed.service.ts b/src/app/clarin-wayf/services/feed.service.ts index 336a0ec5658..10ceb2e96ef 100644 --- a/src/app/clarin-wayf/services/feed.service.ts +++ b/src/app/clarin-wayf/services/feed.service.ts @@ -70,7 +70,14 @@ export class WayfFeedService { return; } - this.entries.set(data.map(raw => normalizeEntry(raw, locale))); + const normalized = data.map(raw => normalizeEntry(raw, locale)); + const seen = new Set(); + const unique = normalized.filter(e => { + if (seen.has(e.entityID)) { return false; } + seen.add(e.entityID); + return true; + }); + this.entries.set(unique); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to load IdP feed'; console.warn('[WAYF] Feed load failed:', message); From 2111feb42330ec35b122c9be00c7f0951781dda4 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:38:41 +0200 Subject: [PATCH 11/17] chore: remove i18n key dependency from routes and drop orphan translation keys --- docs/WAYF-Mental-Model.md | 21 +--- docs/WAYF-Static-Config-Guide.md | 4 +- src/app/clarin-wayf/AGENTS.md | 32 ++--- src/app/clarin-wayf/clarin-wayf-routes.ts | 2 +- .../clarin-wayf/clarin-wayf.component.html | 13 +- src/app/clarin-wayf/clarin-wayf.component.ts | 24 +--- .../idp-card/wayf-idp-card.component.spec.ts | 2 - .../idp-card/wayf-idp-card.component.ts | 5 +- .../idp-list/wayf-idp-list.component.spec.ts | 2 - .../idp-list/wayf-idp-list.component.ts | 9 +- .../wayf-recent-idps.component.spec.ts | 2 - .../recent-idps/wayf-recent-idps.component.ts | 7 +- .../wayf-search-bar.component.spec.ts | 2 - .../search-bar/wayf-search-bar.component.ts | 8 +- src/app/clarin-wayf/index.ts | 1 - .../clarin-wayf/services/i18n.service.spec.ts | 114 ------------------ src/app/clarin-wayf/services/i18n.service.ts | 110 ----------------- src/app/clarin-wayf/wayf.config.ts | 8 -- src/assets/i18n/en.json5 | 14 --- 19 files changed, 36 insertions(+), 344 deletions(-) delete mode 100644 src/app/clarin-wayf/services/i18n.service.spec.ts delete mode 100644 src/app/clarin-wayf/services/i18n.service.ts diff --git a/docs/WAYF-Mental-Model.md b/docs/WAYF-Mental-Model.md index 255ef26f9a9..a6b5e32d357 100644 --- a/docs/WAYF-Mental-Model.md +++ b/docs/WAYF-Mental-Model.md @@ -14,7 +14,7 @@ |------|---------------| | **Orchestrator** (`ClarinWayfComponent`) | Loads IdP feed, wires search/pagination/selection, handles SAMLDS redirect protocol | | **UI Kit** (4 sub-components) | Card, list, search bar, recent-IdP shortcut — pure presentation, no business logic | -| **Service Layer** (4 services) | Feed fetching, fuzzy search, localStorage persistence, self-contained i18n | +| **Service Layer** (3 services) | Feed fetching, fuzzy search, localStorage persistence | | **Integration Glue** (3 touch points) | Login page toggle, header dropdown tab, Shibboleth auth method button | --- @@ -47,8 +47,7 @@ src/app/clarin-wayf/ ├── Services (all signal-based, no external deps) │ ├── feed.service.ts ← HTTP fetch + cache │ ├── search.service.ts ← Fuzzy search engine -│ ├── persistence.service.ts ← localStorage wrapper -│ └── i18n.service.ts ← Built-in translations (en/cs/de) +│ └── persistence.service.ts ← localStorage wrapper │ ├── UI Components (pure presentation) │ ├── components/search-bar/ ← Text input with ARIA @@ -56,7 +55,7 @@ src/app/clarin-wayf/ │ ├── components/idp-card/ ← Single institution card │ └── components/recent-idps/ ← "Continue with..." shortcut │ -└── Tests (10 spec files, 136 tests total) +└── Tests (9 spec files, 112 tests total) Integration points (outside the module): ├── login-page.component.ts/html ← Toggle button + collapsible panel @@ -69,12 +68,12 @@ Integration points (outside the module): # ⚫ Black Box Summary ### ClarinWayfComponent -- **Inputs:** `feedUrl`, `spEntityId`, `loginEndpoint`, `maxResults`, `locale`, `pinnedIdps`, ... +- **Inputs:** `feedUrl`, `spEntityId`, `loginEndpoint`, `serviceName`, `pinnedIdps`, `localAuthEnabled`, `helpText`, `enableSearch`, `maxResults`, `rememberSelection` - **Outputs:** `idpSelected`, `localAuthSelected`, `cancelled` - **Side effects:** Fetches feed URL on init, writes to `localStorage`, may redirect via `window.location.href` ### WayfFeedService -- **Input:** `feedUrl: string`, `locale: string` +- **Input:** `feedUrl: string`, `locale: string` (defaults to `'en'`) - **Output:** `entries` signal with `IdentityProvider[]` - **Side effects:** HTTP `fetch()` call (credentials: omit) @@ -88,23 +87,17 @@ Integration points (outside the module): - **Output:** `lastIdp` signal with last-used entityID - **Side effects:** Reads/writes `localStorage` key `wayf:last-idp` -### WayfI18nService -- **Input:** Language code (`en`, `cs`, `de`) -- **Output:** `t(key, params?)` → translated string -- **Side effects:** None - --- # 🚨 Overengineered / Suspicious Code | Area | Concern | |------|---------| -| **Custom i18n service** | DSpace already has `@ngx-translate`. This builds a parallel mini-i18n system (15 keys, 3 languages). Justified only if the component must work outside DSpace. | | **Custom fuzzy search** | Sørensen–Dice bigram implementation is solid but complex (~60 lines). A simpler substring+includes filter would cover 95% of use cases. | | **Config resolution chain** | 3-level fallback (`input → WAYF_CONFIG → WAYF_DEFAULTS`) with an `effect()` that merges them. Adds cognitive overhead for a config that rarely changes at runtime. | | **SAMLDS protocol handling** | `parseSamldsParams()` + `sanitizeReturnUrl()` + `isPassive` auto-redirect implement a full SAMLDS client. This complexity lives in the UI component rather than a service. | | **WayfModule.forRoot()** | The module wrapper exists for ergonomic DI setup, but the component is standalone. The module is a thin shell — could be replaced by direct `provide` calls. | -| **4 separate services** | Feed, Search, Persistence, i18n — each is small (~40-60 lines). Could arguably be 2 services (DataService = feed+persistence, SearchService stays). | +| **4 separate services** | Feed, Search, Persistence — each is small (~40-60 lines). Could arguably be 2 services (DataService = feed+persistence, SearchService stays). | --- @@ -112,7 +105,6 @@ Integration points (outside the module): | Suggestion | Risk | Impact | |------------|------|--------| -| **Replace custom i18n** with `@ngx-translate` keys in DSpace's existing `en.json5` | Low | Removes ~120 lines + 13 tests. Breaks standalone portability. | | **Inline persistence into main component** | Low | Removes a file + 8 tests. The service is just 3 `localStorage` calls. | | **Simplify search** to substring-only, drop Dice coefficient | Medium | Removes ~40 lines + some tests. Loses typo tolerance (e.g. "Univerzita" → "Universita"). | | **Move SAMLDS logic to a utility function** | Low | Main component becomes cleaner. Pure function is easier to test. | @@ -132,7 +124,6 @@ User clicks "Select institution" → List renders in IdpListComponent (cards) → User types → SearchService filters (fuzzy match) → User clicks a card - → PersistenceService saves to localStorage → Component builds Shibboleth redirect URL → Browser navigates to institution login ``` diff --git a/docs/WAYF-Static-Config-Guide.md b/docs/WAYF-Static-Config-Guide.md index 8a5d9ff2520..01e6d4013e0 100644 --- a/docs/WAYF-Static-Config-Guide.md +++ b/docs/WAYF-Static-Config-Guide.md @@ -147,7 +147,7 @@ export const ROUTES: Route[] = [ pathMatch: 'full', component: ClarinWayfComponent, resolve: { breadcrumb: i18nBreadcrumbResolver }, - data: { breadcrumbKey: 'wayf', title: 'wayf.title' }, + data: { breadcrumbKey: 'wayf', title: 'Select Your Institution' }, }, ]; ``` @@ -166,7 +166,7 @@ export const ROUTES: Route[] = [ pathMatch: 'full', component: ClarinWayfComponent, resolve: { breadcrumb: i18nBreadcrumbResolver }, - data: { breadcrumbKey: 'wayf', title: 'wayf.title' }, + data: { breadcrumbKey: 'wayf', title: 'Select Your Institution' }, providers: [ // ← ADD { provide: WAYF_CONFIG, diff --git a/src/app/clarin-wayf/AGENTS.md b/src/app/clarin-wayf/AGENTS.md index ae45a308850..147375421b4 100644 --- a/src/app/clarin-wayf/AGENTS.md +++ b/src/app/clarin-wayf/AGENTS.md @@ -23,33 +23,31 @@ src/app/clarin-wayf/ ├── wayf.config.ts ← WayfConfig, WAYF_CONFIG token, WAYF_DEFAULTS, SamldsParams ├── wayf.module.ts ← WayfModule (forRoot() convenience wrapper) ├── clarin-wayf.component.ts ← main orchestrator component -├── clarin-wayf.component.spec.ts ← 17 unit tests +├── clarin-wayf.component.spec.ts ← 13 unit tests ├── clarin-wayf-routes.ts ← standalone route at /wayf ├── models/ │ ├── idp-entry.model.ts ← IdentityProvider, DiscoFeedEntry interfaces + normalize helpers -│ └── idp-entry.model.spec.ts ← 11 unit tests +│ └── idp-entry.model.spec.ts ← 12 unit tests ├── services/ │ ├── search.service.ts ← fuzzy search engine (Sørensen–Dice) -│ ├── search.service.spec.ts ← 33 unit tests +│ ├── search.service.spec.ts ← 27 unit tests │ ├── feed.service.ts ← HTTP fetch + cache of IdP JSON feed -│ ├── feed.service.spec.ts ← 13 unit tests +│ ├── feed.service.spec.ts ← 14 unit tests │ ├── persistence.service.ts ← localStorage (last IdP), SSR-safe -│ ├── persistence.service.spec.ts ← 8 unit tests -│ ├── i18n.service.ts ← signal-based translation (en/cs/de) -│ └── i18n.service.spec.ts ← 13 unit tests +│ └── persistence.service.spec.ts ← 8 unit tests └── components/ ├── idp-card/ │ ├── wayf-idp-card.component.ts ← single IdP card (logo, name, tag badge) - │ └── wayf-idp-card.component.spec.ts ← 9 unit tests + │ └── wayf-idp-card.component.spec.ts ← 10 unit tests ├── search-bar/ │ ├── wayf-search-bar.component.ts ← search input with ARIA combobox │ └── wayf-search-bar.component.spec.ts ← 7 unit tests ├── idp-list/ │ ├── wayf-idp-list.component.ts ← filtered list of IdP cards - │ └── wayf-idp-list.component.spec.ts ← 10 unit tests + │ └── wayf-idp-list.component.spec.ts ← 11 unit tests └── recent-idps/ ├── wayf-recent-idps.component.ts ← strip of recently used IdPs - └── wayf-recent-idps.component.spec.ts ← 9 unit tests + └── wayf-recent-idps.component.spec.ts ← 10 unit tests ``` --- @@ -75,13 +73,7 @@ src/app/clarin-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 +### 5. 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) --- @@ -144,14 +136,14 @@ The backend endpoint returns 204 when feeds haven't cached yet (handled graceful npm test -- --include='src/app/clarin-wayf/**/*.spec.ts' ``` -All **136 tests** across 10 spec files should pass (verified April 2026). +All **112 tests** across 9 spec files should pass (verified April 2026). --- ## TODO / Next Steps - [x] **Production feed URL**: Auto-derived from `APP_CONFIG.rest.baseUrl` → `/api/discojuice/feeds` -- [x] **Component tests**: 136 tests across all services, components, and models (April 2026) +- [x] **Component tests**: 112 tests across all services, components, and models (April 2026) - [x] **Security hardening**: URL sanitization, feed URL validation, SSR guards (April 2026) - [x] **Type safety**: Zero `as any` casts; fully typed config resolution (April 2026) - [x] **Barrel file / public API**: `index.ts` exports all public symbols (April 2026) @@ -166,5 +158,5 @@ All **136 tests** across 10 spec files should pass (verified April 2026). - **Themed components**: DSpace uses a `src/themes/custom/` shadow that re-exports base components with their own `imports` array. Whenever you add a new component to a base component's template, you **must also add it to the themed wrapper's `imports`**. Forgetting this causes `Unknown element 'ds-...'` errors only in the themed variant. - **TypeScript config**: `noImplicitAny: false` and `strictNullChecks: false` — code is permissive but `fullTemplateTypeCheck: true` means template errors are strict. -- **i18n**: After adding keys to `en.json5`, restart the dev server — the asset hash changes and the old bundle won't pick up new keys. +- **i18n**: The WAYF component has no i18n dependency — all UI text is hardcoded English. It is fully portable to any Angular host app. - **Auth method decorator map**: `AUTH_METHOD_FOR_DECORATOR_MAP` in `log-in.methods-decorator.ts` is the single source of truth for which component renders for each `AuthMethodType`. Update both the map and the `AuthMethodTypeComponent` union type together. diff --git a/src/app/clarin-wayf/clarin-wayf-routes.ts b/src/app/clarin-wayf/clarin-wayf-routes.ts index 7a6eacf70b4..d13c173dca5 100644 --- a/src/app/clarin-wayf/clarin-wayf-routes.ts +++ b/src/app/clarin-wayf/clarin-wayf-routes.ts @@ -7,6 +7,6 @@ export const ROUTES: Route[] = [ path: '', pathMatch: 'full', component: ClarinWayfComponent, - data: { title: 'wayf.title' }, + data: { title: 'Select Your Institution' }, }, ]; diff --git a/src/app/clarin-wayf/clarin-wayf.component.html b/src/app/clarin-wayf/clarin-wayf.component.html index 020cf228032..a9b738f97d5 100644 --- a/src/app/clarin-wayf/clarin-wayf.component.html +++ b/src/app/clarin-wayf/clarin-wayf.component.html @@ -2,20 +2,19 @@ @if (resolvedServiceName()) {

{{ resolvedServiceName() }}

} -

{{ resolvedSubtitle() }}

@if (feedService.loading()) {
- {{ i18n.t('wayf.loading') }} + Loading institutions...
-
{{ i18n.t('wayf.loading') }}
+
Loading institutions...
} @if (feedService.error()) { } @@ -40,7 +39,7 @@

{{ resolvedServiceName() }}

@if (searchQuery().length > 0) {
- {{ i18n.t('wayf.search.results', { count: filteredEntries().length }) }} + {{ filteredEntries().length }} institutions found
} } @@ -60,7 +59,7 @@

{{ resolvedServiceName() }}

} @@ -71,7 +70,7 @@

{{ resolvedServiceName() }}

} diff --git a/src/app/clarin-wayf/clarin-wayf.component.ts b/src/app/clarin-wayf/clarin-wayf.component.ts index 8dee4bc5802..b500ba2570d 100644 --- a/src/app/clarin-wayf/clarin-wayf.component.ts +++ b/src/app/clarin-wayf/clarin-wayf.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, computed, - effect, inject, input, OnInit, @@ -17,7 +16,6 @@ import { ActivatedRoute } from '@angular/router'; import { IdentityProvider } from './models/idp-entry.model'; import { SamldsParams, WayfConfig, WAYF_CONFIG, WAYF_DEFAULTS } from './wayf.config'; 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'; @@ -48,7 +46,6 @@ import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-id ], providers: [ WayfFeedService, - WayfI18nService, WayfPersistenceService, WayfSearchService, ], @@ -65,7 +62,6 @@ import { WayfRecentIdpsComponent } from './components/recent-idps/wayf-recent-id `], }) 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); @@ -109,12 +105,6 @@ export class ClarinWayfComponent implements OnInit { /** Remember the last-used IdP in localStorage. */ readonly rememberSelection = input(undefined); - /** Subtitle text shown below the title. */ - readonly subtitle = input(''); - - /** UI locale / language code. */ - readonly locale = input(''); - // ── Outputs ────────────────────────────────────────────────── /** Emits the selected IdP entry. */ @@ -155,12 +145,10 @@ export class ClarinWayfComponent implements OnInit { } readonly resolvedServiceName = computed(() => this.resolve(this.serviceName(), 'serviceName')); - readonly resolvedSubtitle = computed(() => this.resolve(this.subtitle(), 'subtitle')); readonly resolvedEnableSearch = computed(() => this.resolve(this.enableSearch(), 'enableSearch')); readonly resolvedLocalAuthEnabled = computed(() => this.resolve(this.localAuthEnabled(), 'localAuthEnabled')); readonly resolvedHelpText = computed(() => this.resolve(this.helpText(), 'helpText')); readonly resolvedMaxResults = computed(() => this.resolve(this.maxResults(), 'maxResults')); - readonly resolvedLocale = computed(() => this.resolve(this.locale(), 'locale')); /** Resolved pinned IdPs: from input first, then from injected config. */ private readonly resolvedPinnedIdps = computed(() => { @@ -211,16 +199,6 @@ export class ClarinWayfComponent implements OnInit { return m > 0 ? m : 25; } - constructor() { - // Sync locale to i18n service - effect(() => { - const loc = this.resolvedLocale(); - if (loc) { - this.i18n.setLang(loc); - } - }); - } - ngOnInit(): void { this.displayLimit.set(this.pageSize); this.parseSamldsParams(); @@ -315,7 +293,7 @@ export class ClarinWayfComponent implements OnInit { } catch { return; } - const loc = this.resolvedLocale(); + const loc = 'en'; this.feedService.loadFeed(url, loc); } } diff --git a/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts index 53ff39dbf15..0b9fc1c9b88 100644 --- a/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WayfIdpCardComponent } from './wayf-idp-card.component'; -import { WayfI18nService } from '../../services/i18n.service'; import { IdentityProvider } from '../../models/idp-entry.model'; describe('WayfIdpCardComponent', () => { @@ -17,7 +16,6 @@ describe('WayfIdpCardComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [WayfIdpCardComponent], - providers: [WayfI18nService], }).compileComponents(); fixture = TestBed.createComponent(WayfIdpCardComponent); 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 index 55573811eb1..a019ae0704c 100644 --- 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 @@ -1,14 +1,12 @@ import { ChangeDetectionStrategy, Component, - inject, input, output, signal, } from '@angular/core'; import { IdentityProvider } from '../../models/idp-entry.model'; -import { WayfI18nService } from '../../services/i18n.service'; /** * Renders a single IdP entry card with logo, display name, and optional hub badge. @@ -47,14 +45,13 @@ import { WayfI18nService } from '../../services/i18n.service'; @if (isHub()) { - {{ i18n.t('wayf.hub.badge') }} + Hub } `, styleUrls: ['./wayf-idp-card.component.scss'], }) export class WayfIdpCardComponent { - protected readonly i18n = inject(WayfI18nService); /** The IdP entry to display. */ readonly entry = input.required(); diff --git a/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts index adb3423f76b..3e4f24d8522 100644 --- a/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts +++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WayfIdpListComponent } from './wayf-idp-list.component'; -import { WayfI18nService } from '../../services/i18n.service'; import { IdentityProvider } from '../../models/idp-entry.model'; describe('WayfIdpListComponent', () => { @@ -17,7 +16,6 @@ describe('WayfIdpListComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [WayfIdpListComponent], - providers: [WayfI18nService], }).compileComponents(); fixture = TestBed.createComponent(WayfIdpListComponent); 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 index 0c8b7b0e739..6cf89595509 100644 --- 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 @@ -2,14 +2,12 @@ import { ChangeDetectionStrategy, Component, computed, - inject, input, output, signal, } from '@angular/core'; import { IdentityProvider } from '../../models/idp-entry.model'; -import { WayfI18nService } from '../../services/i18n.service'; import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component'; /** @@ -24,7 +22,7 @@ import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component'; id="wayf-idp-listbox" class="wayf-idp-list" role="listbox" - [attr.aria-label]="i18n.t('wayf.a11y.list-label')" + [attr.aria-label]="'List of identity providers'" tabindex="0" (keydown)="onKeydown($event)"> @@ -39,13 +37,13 @@ import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component'; @if (entries().length === 0 && !loading()) {
- {{ i18n.t('wayf.search.no-results') }} + No institutions match your search
}
- {{ i18n.t('wayf.a11y.result-count', { count: entries().length }) }} + {{ entries().length }} results available
`, styles: [` @@ -59,7 +57,6 @@ import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component'; `], }) export class WayfIdpListComponent { - protected readonly i18n = inject(WayfI18nService); /** Sorted/filtered entries to display. */ readonly entries = input.required(); diff --git a/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts index e49a4d088bf..7fa6f0bcd4a 100644 --- a/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts +++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WayfRecentIdpsComponent } from './wayf-recent-idps.component'; -import { WayfI18nService } from '../../services/i18n.service'; import { IdentityProvider } from '../../models/idp-entry.model'; describe('WayfRecentIdpsComponent', () => { @@ -17,7 +16,6 @@ describe('WayfRecentIdpsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [WayfRecentIdpsComponent], - providers: [WayfI18nService], }).compileComponents(); fixture = TestBed.createComponent(WayfRecentIdpsComponent); 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 index 6a8dcc8c137..8dd1ddd18e7 100644 --- 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 @@ -2,13 +2,11 @@ import { ChangeDetectionStrategy, Component, computed, - inject, input, output, } from '@angular/core'; import { IdentityProvider } from '../../models/idp-entry.model'; -import { WayfI18nService } from '../../services/i18n.service'; /** * Shows a single shortcut button for quick IdP selection: @@ -53,7 +51,6 @@ import { WayfI18nService } from '../../services/i18n.service'; `], }) export class WayfRecentIdpsComponent { - protected readonly i18n = inject(WayfI18nService); /** All entries from the feed (needed to resolve names). */ readonly allEntries = input.required(); @@ -99,8 +96,8 @@ export class WayfRecentIdpsComponent { /** Label shown above the institution name. */ readonly shortcutLabel = computed(() => this.isStaticDefault() - ? this.i18n.t('wayf.pinned.label') - : this.i18n.t('wayf.recent.continue'), + ? 'Default institution' + : 'Continue with', ); /** Resolved display name for the shortcut entry. */ diff --git a/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts index ac6d56eb791..898c38b6703 100644 --- a/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts +++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WayfSearchBarComponent } from './wayf-search-bar.component'; -import { WayfI18nService } from '../../services/i18n.service'; describe('WayfSearchBarComponent', () => { let component: WayfSearchBarComponent; @@ -10,7 +9,6 @@ describe('WayfSearchBarComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [WayfSearchBarComponent], - providers: [WayfI18nService], }).compileComponents(); fixture = TestBed.createComponent(WayfSearchBarComponent); 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 index 3f58ae4a6fc..7bccdfdc2e6 100644 --- 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 @@ -2,14 +2,11 @@ import { ChangeDetectionStrategy, Component, ElementRef, - inject, input, output, viewChild, } from '@angular/core'; -import { WayfI18nService } from '../../services/i18n.service'; - /** * Search input bar for filtering IdP entries. */ @@ -18,7 +15,7 @@ import { WayfI18nService } from '../../services/i18n.service'; changeDetection: ChangeDetectionStrategy.OnPush, template: `