diff --git a/angular.json b/angular.json index cf348ef8d54..d9a82ea00dd 100644 --- a/angular.json +++ b/angular.json @@ -276,6 +276,35 @@ } } } + }, + "clarin-wayf": { + "projectType": "library", + "root": "src/app/clarin-wayf", + "sourceRoot": "src/app/clarin-wayf", + "prefix": "wayf", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "src/app/clarin-wayf/ng-package.json", + "tsConfig": "tsconfig.json" + }, + "configurations": { + "production": { + "tsConfig": "tsconfig.json" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "tsconfig.spec.json", + "include": [ + "src/app/clarin-wayf/**/*.spec.ts" + ] + } + } + } } }, "cli": { diff --git a/package-lock.json b/package-lock.json index 1587edd6b88..655c3b31f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -145,6 +145,7 @@ "karma-mocha-reporter": "2.2.5", "md5": "^2.3.0", "ng-mocks": "^14.14.0", + "ng-packagr": "^20.3.2", "ngx-mask": "14.2.4", "postcss": "^8.5", "postcss-import": "^14.0.0", @@ -5068,6 +5069,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -7558,6 +7570,50 @@ "react-dom": "^16.3.2" } }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -7907,6 +7963,26 @@ "win32" ] }, + "node_modules/@rollup/wasm-node": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.60.2.tgz", + "integrity": "sha512-FOfZOg752WSyKNefpSM3WrhggSTSuKuwcSfF7tdWC9PBYYg7BLwBR267uShFAI1ZyA0gNkdqK16LL9mNOPsQ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -10944,6 +11020,13 @@ "node": ">= 12.0.0" } }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -12150,6 +12233,16 @@ "node": ">= 0.8" } }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -13457,6 +13550,13 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -13972,6 +14072,39 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-cache-directory": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-cache-directory/-/find-cache-directory-6.0.0.tgz", + "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-directory/node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -13989,6 +14122,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -15196,6 +15342,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/injection-js": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.6.1.tgz", + "integrity": "sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -18781,6 +18937,56 @@ "@angular/platform-browser": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19 || 20.0.0-alpha - 20" } }, + "node_modules/ng-packagr": { + "version": "20.3.2", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-20.3.2.tgz", + "integrity": "sha512-yW5ME0hqTz38r/th/7zVwX5oSIw1FviSA2PUlGZdVjghDme/KX6iiwmOBmlt9E9whNmwijEC6Gn3KKbrsBx8ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/wasm-node": "^4.24.0", + "ajv": "^8.17.1", + "ansi-colors": "^4.1.3", + "browserslist": "^4.22.1", + "chokidar": "^4.0.1", + "commander": "^14.0.0", + "dependency-graph": "^1.0.0", + "esbuild": "^0.25.0", + "find-cache-directory": "^6.0.0", + "injection-js": "^2.4.0", + "jsonc-parser": "^3.3.1", + "less": "^4.2.0", + "ora": "^8.2.0", + "piscina": "^5.0.0", + "postcss": "^8.4.47", + "rollup-plugin-dts": "^6.2.0", + "rxjs": "^7.8.1", + "sass": "^1.81.0", + "tinyglobby": "^0.2.12" + }, + "bin": { + "ng-packagr": "src/cli/main.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "optionalDependencies": { + "rollup": "^4.24.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^20.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=5.8 <6.0" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } + } + }, "node_modules/ng2-file-upload": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ng2-file-upload/-/ng2-file-upload-9.0.0.tgz", @@ -22483,6 +22689,49 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-dts": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.4.1.tgz", + "integrity": "sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "convert-source-map": "^2.0.0", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.29.0" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0 || ^6.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup-plugin-dts/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", diff --git a/package.json b/package.json index 4b18e68dacf..7848a1d075c 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,7 @@ "karma-mocha-reporter": "2.2.5", "md5": "^2.3.0", "ng-mocks": "^14.14.0", + "ng-packagr": "^20.3.2", "ngx-mask": "14.2.4", "postcss": "^8.5", "postcss-import": "^14.0.0", 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/app.config.ts b/src/app/app.config.ts index e97bfd507f9..e93f2094792 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -63,6 +63,7 @@ import { } from './core/provide-core'; import { ClientCookieService } from './core/services/client-cookie.service'; import { ListableModule } from './core/shared/listable.module'; +import { WAYF_CONFIG, WAYF_DEFAULTS } from './clarin-wayf/wayf.config'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator'; import { RootModule } from './root.module'; @@ -162,6 +163,18 @@ export const commonAppConfig: ApplicationConfig = { // DI-composable menus ...MENUS, + // WAYF component configuration — deployment-specific values + { + provide: WAYF_CONFIG, + useFactory: (appConfig: AppConfig) => ({ + ...WAYF_DEFAULTS, + feedUrl: 'https://lindat.mff.cuni.cz/Shibboleth.sso/DiscoFeed', + spEntityId: '', + loginEndpoint: 'https://lindat.mff.cuni.cz/Shibboleth.sso/Login', + }), + deps: [APP_CONFIG], + }, + provideCore(), ], }; diff --git a/src/app/clarin-wayf/AGENTS.md b/src/app/clarin-wayf/AGENTS.md new file mode 100644 index 00000000000..cdef6950934 --- /dev/null +++ b/src/app/clarin-wayf/AGENTS.md @@ -0,0 +1,174 @@ +# 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 +├── index.ts ← barrel file (public API surface) +├── 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 ← 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 ← 12 unit tests +├── services/ +│ ├── search.service.ts ← fuzzy search engine (Sørensen–Dice) +│ ├── search.service.spec.ts ← 27 unit tests +│ ├── feed.service.ts ← HTTP fetch + cache of IdP JSON feed +│ ├── feed.service.spec.ts ← 14 unit tests +│ ├── persistence.service.ts ← localStorage (last IdP), SSR-safe +│ └── 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 ← 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 ← 11 unit tests + └── recent-idps/ + ├── wayf-recent-idps.component.ts ← strip of recently used IdPs + └── wayf-recent-idps.component.spec.ts ← 10 unit tests +``` + +--- + +## 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. 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. `WAYF_CONFIG` token `feedUrl` value +3. `?feedUrl=` query parameter (for standalone `/wayf` route) +4. Auto-derived from DSpace REST config: `${APP_CONFIG.rest.baseUrl}/api/discojuice/feeds` + +The backend endpoint returns 204 when feeds haven't cached yet (handled gracefully). + +### Fuzzy Search (`search.service.ts`) +A three-tier scoring algorithm designed for international academic federations where institution names appear in local scripts (Czech, German, Portuguese, etc.). + +**Tier 1 — Exact substring** (score = 2): the normalized query appears verbatim in the entry's searchable text (title, keywords, description, country, domain). Fastest path. + +**Tier 2 — Word-level match** (score = 1 + word-hit ratio): each query word is checked individually against the searchable text. Handles multi-word queries like `"charles university"`. + +**Tier 3 — Fuzzy bigram / Sørensen–Dice coefficient** (score = 0.4–1.0): character bigrams of each query word are compared against each text word. Provides typo tolerance — e.g. `"univerzita"` matches `"universita"`, `"masarik"` matches `"masaryk"`. Entries scoring below 0.4 similarity are filtered out. + +**Bonus**: +0.5 when the query matches the `title` field directly (promotes title-first results). + +**Preprocessing**: +- Diacritics stripped via Unicode NFD decomposition + combining-mark removal +- Lowercased, whitespace-collapsed +- Searchable text includes: title, keywords, description, country, domain extracted from entityID URL + +**Why not use a library?** The implementation is ~25 lines for the Dice coefficient, zero external dependencies, and fully covered by 27 unit tests. A library (e.g. Fuse.js) would add ~30 KB for the same result. + +### Persistence (`persistence.service.ts`) +- `wayf:last-idp` key — entityID of last selected IdP +- SSR-safe: all `localStorage` calls guarded with `isPlatformBrowser()` +- Gracefully handles `QuotaExceededError` and disabled storage + +> **Breaking change (April 2026):** localStorage key was renamed from `clarin-wayf-last-idp` to `wayf:last-idp`. Existing users will lose their remembered IdP selection on first visit after upgrade. + +### Security (`clarin-wayf.component.ts`) +- **`sanitizeReturnUrl()`** — validates SAMLDS `return` URL; only `http:` and `https:` schemes allowed (blocks `javascript:`, `data:`, and malformed URLs) +- **Feed URL validation** — `loadFeed()` rejects non-HTTP(S) feed URLs +- **SSR guards** — all `window.location.href` assignments wrapped in `isPlatformBrowser()` + +### Service Scoping +- All 4 services use bare `@Injectable()` (no `providedIn: 'root'`) +- Services are provided at component level via `ClarinWayfComponent.providers` +- This enables multiple independent WAYF instances on the same page + +### Angular Patterns Used +- **Standalone components** throughout (plus `WayfModule` convenience wrapper) +- **`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 +- **`InjectionToken`** (`WAYF_CONFIG`) for external configuration + +--- + +## Running Tests + +```bash +npm test -- --include='src/app/clarin-wayf/**/*.spec.ts' +``` + +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**: 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) +- [ ] **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 +- [ ] **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); + const redirectSpy = spyOn(f.componentInstance as any, 'redirect'); + f.detectChanges(); + + const params = f.componentInstance.samldsParams(); + expect(params.isPassive).toBeTrue(); + expect(params.return).toBe('https://sp.example.org/return'); + expect(redirectSpy).toHaveBeenCalledWith( + 'https://sp.example.org/return?entityID=https%3A%2F%2Fidp.example.org%2Fshibboleth', + ); + }); + }); +}); 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..3471a3bffab --- /dev/null +++ b/src/app/clarin-wayf/clarin-wayf.component.ts @@ -0,0 +1,291 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + 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'; +import { SamldsParams, WayfConfig, WAYF_CONFIG, WAYF_DEFAULTS } from './wayf.config'; +import { WayfFeedService } from './services/feed.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 WAYF (Where Are You From) component — standalone IdP discovery widget. + * + * Supports two usage modes: + * + * 1. **SAMLDS Discovery Service** — reads entityID, return, returnIDParam, + * isPassive from query params and redirects after IdP selection. + * + * 2. **Embedded IdP picker** — emits `idpSelected` / `localAuthSelected` / + * `cancelled` events for the host application to handle. + * + * All IdP data, endpoints, and branding are configurable via inputs + * or the WAYF_CONFIG injection token — no hardcoded values. + */ +@Component({ + selector: 'ds-clarin-wayf', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + WayfSearchBarComponent, + WayfIdpListComponent, + WayfRecentIdpsComponent, + ], + providers: [ + WayfFeedService, + WayfPersistenceService, + WayfSearchService, + ], + templateUrl: './clarin-wayf.component.html', + styleUrls: ['./clarin-wayf.component.scss'], +}) +export class ClarinWayfComponent implements OnInit { + protected readonly feedService = inject(WayfFeedService); + protected readonly persistence = inject(WayfPersistenceService); + private readonly searchService = inject(WayfSearchService); + private readonly route = inject(ActivatedRoute, { optional: true }); + private readonly wayfConfig = inject(WAYF_CONFIG); + private readonly platformId = inject(PLATFORM_ID); + + // ── Required input ─────────────────────────────────────────── + + /** URL to the JSON IdP feed. */ + readonly feedUrl = input(''); + + // ── Recommended inputs ─────────────────────────────────────── + + /** Branding title shown in the overlay header. */ + readonly serviceName = input(''); + + /** Always-visible priority IdP entries. */ + readonly pinnedIdps = input([]); + + /** Show a "Local authentication" fallback option. */ + readonly localAuthEnabled = input(undefined); + + /** Guidance text for "Can't find my provider". */ + readonly helpText = input(''); + + // ── Optional inputs ────────────────────────────────────────── + + /** Enable the search bar. */ + readonly enableSearch = input(undefined); + + /** Maximum number of results shown in the list. */ + readonly maxResults = input(undefined); + + /** Remember the last-used IdP in localStorage. */ + readonly rememberSelection = input(undefined); + + // ── Outputs ────────────────────────────────────────────────── + + /** Emits the selected IdP entry. */ + readonly idpSelected = output(); + + /** Emits when the user picks local authentication. */ + readonly localAuthSelected = output(); + + /** Emits when the user closes without choosing. */ + readonly cancelled = output(); + + // ── 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, + return: null, + returnIDParam: 'entityID', + isPassive: false, + }); + + // ── 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: WayfConfig[K] | '' | undefined, + key: K, + ): WayfConfig[K] { + if (inputValue !== '' && inputValue !== undefined) { return inputValue as WayfConfig[K]; } + const configuredValue = this.wayfConfig[key]; + if (configuredValue !== undefined) { return configuredValue as WayfConfig[K]; } + return WAYF_DEFAULTS[key as keyof typeof WAYF_DEFAULTS] as WayfConfig[K]; + } + + readonly resolvedServiceName = computed(() => this.resolve(this.serviceName(), 'serviceName')); + 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')); + + /** 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.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.resolvedPinnedIdps(); + const feedIds = new Set(feed.map(e => e.entityID)); + const extra = pinned.filter(p => !feedIds.has(p.entityID)); + return [...extra, ...feed]; + }); + + /** Entries filtered by search query. */ + readonly filteredEntries = computed(() => + this.searchService.filterEntries( + this.allDisplayEntries(), + this.searchQuery(), + ), + ); + + /** Visible entries, capped at displayLimit. */ + readonly displayEntries = computed(() => { + const filtered = this.filteredEntries(); + 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; + } + + 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) { + this.persistence.selectIdp(entry.entityID); + } + + this.idpSelected.emit(entry); + + const params = this.samldsParams(); + if (params.return && isPlatformBrowser(this.platformId)) { + const separator = params.return.includes('?') ? '&' : '?'; + const redirectUrl = `${params.return}${separator}${encodeURIComponent(params.returnIDParam)}=${encodeURIComponent(entry.entityID)}`; + this.redirect(redirectUrl); + } + } + + onArrowDown(): void { + this.idpList()?.activeIndex.set(0); + } + + onFocusSearch(): void { + this.searchBar()?.focusInput(); + } + + onEscaped(): void { + this.searchQuery.set(''); + } + + private parseSamldsParams(): void { + const queryParams = this.route?.snapshot.queryParams ?? {}; + const rawReturn = queryParams['return'] ?? null; + this.samldsParams.set({ + entityID: queryParams['entityID'] ?? null, + return: this.sanitizeReturnUrl(rawReturn), + returnIDParam: queryParams['returnIDParam'] ?? 'entityID', + isPassive: queryParams['isPassive'] === 'true', + }); + + if (this.samldsParams().isPassive && isPlatformBrowser(this.platformId)) { + 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)}`; + this.redirect(redirectUrl); + } + } + } + + /** Perform a full-page redirect. Extracted for testability. */ + protected redirect(url: string): void { + window.location.href = url; + } + + /** + * 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 = 'en'; + this.feedService.loadFeed(url, loc); + } +} 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..9325df1757e --- /dev/null +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.scss @@ -0,0 +1,55 @@ +:host { + display: block; +} + +.wayf-idp-card { + gap: 0.75rem; + cursor: pointer; + 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; + color: var(--bs-secondary-color, #6c757d); + } + } + + &__logo-icon { + width: 1.25rem; + height: 1.25rem; + fill: currentColor; + } + + &__info { + min-width: 0; + } +} + +.min-w-0 { + min-width: 0; +} 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..eeb734c77e5 --- /dev/null +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.spec.ts @@ -0,0 +1,101 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfIdpCardComponent } from './wayf-idp-card.component'; +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], + }).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('.wayf-idp-card__logo--placeholder'); + 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('.wayf-idp-card__logo--placeholder')).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('.wayf-idp-card__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('.wayf-idp-card__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-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..2877b2308ea --- /dev/null +++ b/src/app/clarin-wayf/components/idp-card/wayf-idp-card.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core'; + +import { IdentityProvider } from '../../models/idp-entry.model'; + +/** + * Renders a single IdP entry card with logo, display name, and optional hub badge. + */ +@Component({ + selector: 'ds-wayf-idp-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ @if (entry().logoUrl && !logoFailed()) { + + } @else { + + } +
+ +
+
{{ entry().title }}
+
{{ entry().entityID }}
+
+ + @if (isHub()) { + Hub + } +
+ `, + styleUrls: ['./wayf-idp-card.component.scss'], +}) +export class WayfIdpCardComponent { + + /** 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(); + + /** Flips to true when the logo fires an error (404, invalid URL, etc.). */ + readonly logoFailed = signal(false); +} diff --git a/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.scss b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.scss new file mode 100644 index 00000000000..7c7d4bcdf7d --- /dev/null +++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.scss @@ -0,0 +1,13 @@ +.wayf-idp-list { + max-height: 400px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.wayf-idp-list__empty { + border: 1px dashed var(--bs-border-color, #dee2e6); + border-radius: 0.75rem; + background: var(--bs-secondary-bg, #f8f9fa); +} 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..3e4f24d8522 --- /dev/null +++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.spec.ts @@ -0,0 +1,113 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfIdpListComponent } from './wayf-idp-list.component'; +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], + }).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/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..b2d364325c7 --- /dev/null +++ b/src/app/clarin-wayf/components/idp-list/wayf-idp-list.component.ts @@ -0,0 +1,106 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; + +import { IdentityProvider } from '../../models/idp-entry.model'; +import { WayfIdpCardComponent } from '../idp-card/wayf-idp-card.component'; + +/** + * Scrollable list of IdP cards with keyboard navigation. + */ +@Component({ + selector: 'ds-wayf-idp-list', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [WayfIdpCardComponent], + template: ` +
+ + @for (entry of entries(); track entry.entityID; let i = $index) { + + } + + @if (entries().length === 0 && !loading()) { +
+ No institutions match your search +
+ } +
+ +
+ {{ entries().length }} results available +
+ `, + styleUrls: ['./wayf-idp-list.component.scss'], +}) +export class WayfIdpListComponent { + + /** 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.scss b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.scss new file mode 100644 index 00000000000..ffb7102bc80 --- /dev/null +++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.scss @@ -0,0 +1,10 @@ +.wayf-shortcut__card { + cursor: pointer; + background-color: var(--bs-primary-bg-subtle, #e7f1ff); + border-color: var(--bs-primary, #0d6efd) !important; + transition: background-color 0.15s ease; +} + +.wayf-shortcut__card:hover { + background-color: var(--bs-primary-bg-subtle, #cfe2ff); +} 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..4e19292f10c --- /dev/null +++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.spec.ts @@ -0,0 +1,114 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfRecentIdpsComponent } from './wayf-recent-idps.component'; +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], + }).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('.wayf-shortcut__label')?.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('.wayf-shortcut__label')?.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/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..a36e77e196b --- /dev/null +++ b/src/app/clarin-wayf/components/recent-idps/wayf-recent-idps.component.ts @@ -0,0 +1,100 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; + +import { IdentityProvider } from '../../models/idp-entry.model'; + +/** + * Shows a single shortcut button for quick IdP selection: + * - If a static default IdP is configured → shows it as "Default institution". + * - Otherwise, if the user has a last-used IdP → shows it as "Continue with". + * - If neither exists → nothing is rendered. + */ +@Component({ + selector: 'ds-wayf-recent-idps', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (shortcutEntry(); as entry) { +
+
+
+ +
+
+
{{ shortcutLabel() }}
+
{{ shortcutDisplayName() }}
+
+
+
+ } + `, + styleUrls: ['./wayf-recent-idps.component.scss'], +}) +export class WayfRecentIdpsComponent { + + /** All entries from the feed (needed to resolve names). */ + readonly allEntries = input.required(); + + /** The entityID of the last selected IdP. */ + readonly lastIdpEntityId = input(null); + + /** + * EntityID of the statically configured default IdP (from WAYF_CONFIG or input). + * When set, this takes priority over the last-used IdP. + */ + readonly defaultEntityId = input(null); + + /** Emits when the shortcut IdP is selected. */ + readonly idpSelected = output(); + + /** Whether we are showing a static default (true) or a last-used entry (false). */ + private readonly isStaticDefault = computed(() => { + const defId = this.defaultEntityId(); + return !!defId && this.allEntries().some(e => e.entityID === defId); + }); + + /** The single IdP entry to show: static default wins, then last-used. */ + readonly shortcutEntry = computed(() => { + const all = this.allEntries(); + + // Priority 1: static default + const defId = this.defaultEntityId(); + if (defId) { + const found = all.find(e => e.entityID === defId); + if (found) { return found; } + } + + // Priority 2: last-used + const lastId = this.lastIdpEntityId(); + if (lastId) { + return all.find(e => e.entityID === lastId) ?? null; + } + + return null; + }); + + /** Label shown above the institution name. */ + readonly shortcutLabel = computed(() => + this.isStaticDefault() + ? 'Default institution' + : 'Continue with', + ); + + /** Resolved display name for the shortcut entry. */ + readonly shortcutDisplayName = computed(() => { + const entry = this.shortcutEntry(); + if (!entry) { return ''; } + return entry.title; + }); +} diff --git a/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.scss b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.scss new file mode 100644 index 00000000000..601275a1286 --- /dev/null +++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.scss @@ -0,0 +1,7 @@ +.wayf-search-bar { + margin-bottom: 0.75rem; +} + +.input-group-text { + background-color: var(--bs-body-bg, #fff); +} 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..d295e1bafc7 --- /dev/null +++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WayfSearchBarComponent } from './wayf-search-bar.component'; + +describe('WayfSearchBarComponent', () => { + let component: WayfSearchBarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WayfSearchBarComponent], + }).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')).toMatch(/^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/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..5037ff9e473 --- /dev/null +++ b/src/app/clarin-wayf/components/search-bar/wayf-search-bar.component.ts @@ -0,0 +1,76 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + output, + viewChild, +} from '@angular/core'; + +/** + * Search input bar for filtering IdP entries. + */ +@Component({ + selector: 'ds-wayf-search-bar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrls: ['./wayf-search-bar.component.scss'], +}) +export class WayfSearchBarComponent { + + private static nextInputId = 0; + + readonly inputId = `wayf-search-input-${WayfSearchBarComponent.nextInputId++}`; + + /** 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/index.ts b/src/app/clarin-wayf/index.ts new file mode 100644 index 00000000000..4ce8151964a --- /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'; +export { CLARIN_WAYF_ROUTES, ROUTES } from './clarin-wayf-routes'; + +// Models +export { IdentityProvider, DiscoFeedEntry, DiscoFeedLocalizedValue, DiscoFeedLogoEntry } from './models/idp-entry.model'; + +// Services (for advanced use / testing) +export { WayfFeedService } from './services/feed.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/models/idp-entry.model.ts b/src/app/clarin-wayf/models/idp-entry.model.ts new file mode 100644 index 00000000000..5f62b496eb2 --- /dev/null +++ b/src/app/clarin-wayf/models/idp-entry.model.ts @@ -0,0 +1,129 @@ +/** + * Normalized Identity Provider entry — the component's internal data model. + * + * All feed formats (Shibboleth DiscoFeed, custom backends, static JSON) + * are normalized to this shape by the feed service before use. + */ +export interface IdentityProvider { + /** SAML entityID (unique identifier). */ + entityID: string; + + /** Human-readable display name (already resolved from localized arrays). */ + title: string; + + /** ISO 3166-1 alpha-2 country code (e.g. "CZ", "DE"). */ + country?: string; + + /** Geographic coordinates for distance-based sorting. */ + geo?: { lat: number; lon: number }; + + /** Sort weight — higher values appear first within equal search scores. */ + weight?: number; + + /** Short description of the provider. */ + description?: string; + + /** URL to the provider's logo image. */ + logoUrl?: string; + + /** Flat keyword list for search matching. */ + keywords?: string[]; +} + +// ── Raw DiscoFeed types (used only during normalization) ──────────────── + +/** A localized value from the standard Shibboleth DiscoFeed. */ +export interface DiscoFeedLocalizedValue { + value: string; + lang?: string; +} + +/** A logo entry from the standard Shibboleth DiscoFeed. */ +export interface DiscoFeedLogoEntry { + value: string; + height?: string | number; + width?: string | number; + lang?: string; +} + +/** + * Raw entry shape from a Shibboleth DiscoFeed endpoint. + * `/Shibboleth.sso/DiscoFeed` returns an array of these. + */ +export interface DiscoFeedEntry { + entityID: string; + DisplayNames?: DiscoFeedLocalizedValue[]; + Descriptions?: DiscoFeedLocalizedValue[]; + Logos?: DiscoFeedLogoEntry[]; + Keywords?: DiscoFeedLocalizedValue[]; + InformationURLs?: DiscoFeedLocalizedValue[]; + PrivacyStatementURLs?: DiscoFeedLocalizedValue[]; +} + +/** + * Resolve a single value from a localized array. + * Tries: exact lang match → 'en' fallback → first entry → fallback. + */ +export function resolveLocalized( + values: DiscoFeedLocalizedValue[] | undefined, + lang: string, + fallback = '', +): string { + if (!values?.length) { + return fallback; + } + const exact = values.find(v => v.lang === lang); + if (exact) { return exact.value; } + const en = values.find(v => v.lang === 'en'); + if (en) { return en.value; } + return values[0].value; +} + +/** + * Pick the best logo URL from a DiscoFeed Logos array. + * Prefers a small logo (height ≤ 60px); falls back to the first one. + */ +function pickLogoUrl(logos: DiscoFeedLogoEntry[] | undefined): string | undefined { + if (!logos?.length) { + return undefined; + } + const small = logos.find(l => l.height != null && Number(l.height) <= 60); + return (small ?? logos[0]).value || undefined; +} + +/** + * Normalize a raw DiscoFeed entry to the flat IdentityProvider model. + */ +export function normalizeDiscoFeedEntry( + raw: DiscoFeedEntry, + lang: string, +): IdentityProvider { + return { + entityID: raw.entityID, + title: resolveLocalized(raw.DisplayNames, lang, raw.entityID), + description: resolveLocalized(raw.Descriptions, lang) || undefined, + logoUrl: pickLogoUrl(raw.Logos), + keywords: raw.Keywords?.map(k => k.value), + }; +} + +/** + * Detect whether a raw JSON entry is a DiscoFeed entry (has DisplayNames) + * or already an IdentityProvider (has title). Normalize accordingly. + */ +export function normalizeEntry(raw: any, lang: string): IdentityProvider { + if (raw.DisplayNames || raw.Logos || raw.Keywords) { + return normalizeDiscoFeedEntry(raw as DiscoFeedEntry, lang); + } + // Already in IdentityProvider-like shape — pass through with defaults + return { + entityID: raw.entityID, + title: raw.title ?? raw.entityID, + country: raw.country, + geo: raw.geo, + weight: raw.weight, + description: raw.description, + logoUrl: raw.logoUrl, + keywords: raw.keywords, + }; +} diff --git a/src/app/clarin-wayf/ng-package.json b/src/app/clarin-wayf/ng-package.json new file mode 100644 index 00000000000..97e279dc106 --- /dev/null +++ b/src/app/clarin-wayf/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/clarin-wayf", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/src/app/clarin-wayf/package.json b/src/app/clarin-wayf/package.json new file mode 100644 index 00000000000..016376a93d5 --- /dev/null +++ b/src/app/clarin-wayf/package.json @@ -0,0 +1,23 @@ +{ + "name": "@dspace/clarin-wayf", + "version": "0.1.0", + "description": "Standalone CLARIN WAYF Angular component library", + "license": "BSD-3-Clause", + "sideEffects": false, + "peerDependencies": { + "@angular/core": "^20.0.0", + "@angular/common": "^20.0.0", + "@angular/router": "^20.0.0", + "rxjs": "^7.8.0", + "bootstrap": "^5.3.0", + "@fortawesome/fontawesome-free": "^6.7.0" + }, + "peerDependenciesMeta": { + "bootstrap": { + "optional": false + }, + "@fortawesome/fontawesome-free": { + "optional": false + } + } +} 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..8646ae2b52f --- /dev/null +++ b/src/app/clarin-wayf/services/feed.service.spec.ts @@ -0,0 +1,186 @@ +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' }, + ); + }); + + 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', () => { + 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 new file mode 100644 index 00000000000..10ceb2e96ef --- /dev/null +++ b/src/app/clarin-wayf/services/feed.service.ts @@ -0,0 +1,90 @@ +import { + inject, + Injectable, + PLATFORM_ID, + signal, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +import { IdentityProvider, normalizeEntry } from '../models/idp-entry.model'; + +/** + * Service to fetch and cache the IdP feed. + * + * Uses the native `fetch()` API instead of Angular's `HttpClient` to avoid + * DSpace's global `withCredentials` interceptor, which causes CORS failures + * when the remote DiscoFeed server returns `Access-Control-Allow-Origin: *`. + * + * Accepts any JSON array of IdP entries — standard Shibboleth DiscoFeed + * (with `DisplayNames[]`, `Logos[]`, etc.) or the flat `IdentityProvider` + * format (with `title`, `logoUrl`, etc.). Entries are auto-detected and + * normalized to `IdentityProvider` on load. + */ +@Injectable() +export class WayfFeedService { + + private readonly platformId = inject(PLATFORM_ID); + + /** All IdP entries loaded from the feed (normalized). */ + 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 and normalize entries. + * + * @param feedUrl URL returning a JSON array of IdP entries. + * @param locale Language code for resolving localized DiscoFeed values. + */ + async loadFeed(feedUrl: string, locale = 'en'): Promise { + if (!isPlatformBrowser(this.platformId)) { + return; // SSR — skip fetch + } + + this.loading.set(true); + this.error.set(null); + + try { + const response = await fetch(feedUrl, { + credentials: 'omit', + }); + + if (!response.ok) { + 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)) { + this.entries.set([]); + return; + } + + 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); + this.error.set(message); + this.entries.set([]); + } finally { + this.loading.set(false); + } + } +} 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 new file mode 100644 index 00000000000..a2f79b48577 --- /dev/null +++ b/src/app/clarin-wayf/services/persistence.service.ts @@ -0,0 +1,54 @@ +import { + inject, + Injectable, + PLATFORM_ID, + signal, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +/** 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() +export class WayfPersistenceService { + + private readonly platformId = inject(PLATFORM_ID); + + /** The entityID of the last selected IdP. */ + readonly lastIdp = signal(this.readLast()); + + /** Record an IdP selection. */ + selectIdp(entityID: string): void { + this.lastIdp.set(entityID); + 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(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(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 new file mode 100644 index 00000000000..ac224bc55aa --- /dev/null +++ b/src/app/clarin-wayf/services/search.service.spec.ts @@ -0,0 +1,226 @@ +import { TestBed } from '@angular/core/testing'; + +import { IdentityProvider } from '../models/idp-entry.model'; +import { WayfSearchService } from './search.service'; + +/** + * Helper: build a minimal IdentityProvider for testing. + */ +function makeEntry(overrides: Partial & { entityID: string }): IdentityProvider { + return { + title: overrides.entityID, + ...overrides, + } as IdentityProvider; +} + +describe('WayfSearchService', () => { + let service: WayfSearchService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [WayfSearchService], + }); + 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()', () => { + it('should return the title field directly', () => { + const entry = makeEntry({ + entityID: 'e1', + title: 'Masaryk University', + }); + expect(service.resolveDisplayName(entry)).toBe('Masaryk University'); + }); + + it('should return the title regardless of locale', () => { + const entry = makeEntry({ + entityID: 'e1', + title: 'Masarykova univerzita', + }); + expect(service.resolveDisplayName(entry)).toBe('Masarykova univerzita'); + }); + + it('should return entityID as fallback when title equals entityID', () => { + const entry = makeEntry({ entityID: 'https://idp.example.org' }); + expect(service.resolveDisplayName(entry)).toBe('https://idp.example.org'); + }); + }); + + // ── 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)', () => { + 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', + title: 'Masaryk University', + keywords: ['masaryk', 'brno', 'czech republic', 'muni', 'masarykova univerzita'], + }); + + 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', () => { + const score = service.scoreEntry(masaryk, 'masarky'); + expect(score).toBeGreaterThan(0); + }); + }); + + // ── filterEntries() ──────────────────────────────────────── + + describe('filterEntries()', () => { + const entries: IdentityProvider[] = [ + makeEntry({ + entityID: 'https://idp.example.org/shibboleth', + title: 'Example University', + keywords: ['example', 'research'], + }), + makeEntry({ + entityID: 'https://shibboleth.muni.cz/idp/shibboleth', + title: 'Masaryk University', + keywords: ['masaryk', 'brno', 'czech republic', 'masarykova univerzita'], + }), + makeEntry({ + entityID: 'https://idp.cuni.cz/idp/shibboleth', + title: 'Charles University', + keywords: ['charles', 'prague', 'univerzita karlova'], + }), + ]; + + it('should return all entries for empty query', () => { + expect(service.filterEntries(entries, '').length).toBe(3); + }); + + it('should filter to matching entries only', () => { + const result = service.filterEntries(entries, 'Masaryk'); + 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'); + expect(result.length).toBe(1); + }); + + it('should match diacritics-insensitively', () => { + const result = service.filterEntries(entries, 'univerzita'); + // 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"', () => { + const result = service.filterEntries(entries, 'University'); + expect(result.length).toBe(3); + }); + + it('should rank exact matches higher than partial matches', () => { + const result = service.filterEntries(entries, 'charles'); + expect(result[0].entityID).toBe('https://idp.cuni.cz/idp/shibboleth'); + }); + + it('should match by keywords', () => { + const result = service.filterEntries(entries, 'Karlova'); + 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'); + 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..83046b4dbc8 --- /dev/null +++ b/src/app/clarin-wayf/services/search.service.ts @@ -0,0 +1,179 @@ +import { + Injectable, + signal, +} from '@angular/core'; + +import { IdentityProvider } from '../models/idp-entry.model'; + +/** + * Fuzzy search service for filtering IdP entries. + * Handles diacritics normalization, typo tolerance via bigram similarity, + * and display name resolution. + */ +@Injectable() +export class WayfSearchService { + + /** Current search query. */ + readonly query = signal(''); + + /** + * 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 display name for an IdP entry. + * Returns the `title` field directly (already resolved during normalization). + */ + resolveDisplayName(entry: IdentityProvider): string { + return entry.title; + } + + /** + * Collect all searchable text for an IdP entry. + */ + getSearchableText(entry: IdentityProvider): string { + const parts: string[] = [entry.title]; + if (entry.keywords?.length) { + parts.push(...entry.keywords); + } + if (entry.description) { + parts.push(entry.description); + } + if (entry.country) { + parts.push(entry.country); + } + parts.push(this.extractDomain(entry.entityID)); + parts.push(entry.entityID); + return parts.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: IdentityProvider, 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. + */ + filterEntries(entries: IdentityProvider[], query: string): IdentityProvider[] { + if (!query || query.trim().length === 0) { + return entries; + } + + const scored = entries + .map(entry => { + let score = this.scoreEntry(entry, query); + + // Bonus for matching the title directly + const title = entry.title; + if (title) { + const normalizedTitle = this.normalize(title); + const normalizedQuery = this.normalize(query); + if (normalizedTitle.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/clarin-wayf/wayf.config.ts b/src/app/clarin-wayf/wayf.config.ts new file mode 100644 index 00000000000..4f8040ccfc5 --- /dev/null +++ b/src/app/clarin-wayf/wayf.config.ts @@ -0,0 +1,132 @@ +import { InjectionToken } from '@angular/core'; +import { IdentityProvider } from './models/idp-entry.model'; + +// ── Interface ────────────────────────────────────────────────── + +/** + * Configuration for the WAYF (Where Are You From) component. + * + * `feedUrl` is the only field required by the standalone widget itself. + * Host applications may also supply integration fields such as + * `loginEndpoint` when they build the surrounding sign-in redirect flow. + * All other widget fields have sensible defaults that can be overridden + * per-instance via component inputs or via the token. + * + * Resolution priority for each field: + * 1. Component `@Input()` binding (e.g. `[feedUrl]="…"`) + * 2. Injected `WAYF_CONFIG` token value + * 3. Built-in default from `WAYF_DEFAULTS` + */ +export interface WayfConfig { + // ── Required by the standalone widget ──────────────────────── + + /** URL of the JSON IdP feed (Shibboleth DiscoFeed or IdentityProvider[]). */ + feedUrl: string; + + // ── Host integration fields (used outside the core widget) ── + + /** SAML entityID of the Service Provider. */ + spEntityId: string; + + /** Shibboleth SP login endpoint for redirect after IdP selection. */ + loginEndpoint: string; + + // ── Optional widget fields (have defaults in WAYF_DEFAULTS) ── + + /** Branding title shown in the overlay header. */ + serviceName: string; + + /** Always-visible priority IdP entries. */ + pinnedIdps: IdentityProvider[]; + + /** Show a "Local authentication" fallback button. */ + localAuthEnabled: boolean; + + /** Guidance text for "Can't find my provider". */ + helpText: string; + + /** + * Custom redirect builder. + * Given the selected IdP and a return URL, returns the full redirect URL. + * `null` → standard Shibboleth redirect. + */ + redirectStrategy: ((idp: IdentityProvider, returnUrl: string) => string) | null; + + /** ISO 3166-1 alpha-2 default country code (e.g. `"CZ"`). */ + defaultCountry: string; + + /** Enable browser geolocation for sorting by proximity. */ + enableGeolocation: boolean; + + /** Enable the search bar. */ + enableSearch: boolean; + + /** Maximum number of results shown in the list. */ + maxResults: number; + + /** Remember the last-used IdP in localStorage. */ + rememberSelection: boolean; + + /** URL of a country-detection API. `null` disables auto-detection. */ + countryApiUrl: string | null; + + /** Show country flags next to IdP names. */ + showFlags: boolean; +} + +// ── Defaults (optional fields only — zero deployment values) ─── + +/** + * Defaults for optional fields. + * Contains **no URLs, entityIDs, or deployment-specific values**. + */ +export const WAYF_DEFAULTS: Omit = { + serviceName: '', + pinnedIdps: [], + localAuthEnabled: false, + helpText: '', + redirectStrategy: null, + defaultCountry: '', + enableGeolocation: true, + enableSearch: true, + maxResults: 25, + rememberSelection: true, + countryApiUrl: null, + showFlags: true, +}; + +// ── Injection token ──────────────────────────────────────────── + +/** + * Injection token for WAYF configuration. + * + * Provide via `WayfModule.forRoot(config)` or manually: + * + * ```ts + * providers: [{ provide: WAYF_CONFIG, useValue: myConfig }] + * ``` + * + * Defaults to an empty object so the standalone component can still be used + * with direct inputs only. + */ +export const WAYF_CONFIG = new InjectionToken>('WAYF_CONFIG', { + providedIn: 'root', + factory: () => ({}), +}); + +// ── SAMLDS params ────────────────────────────────────────────── + +/** + * 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 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/wayf.module.ts b/src/app/clarin-wayf/wayf.module.ts new file mode 100644 index 00000000000..809f40892fd --- /dev/null +++ b/src/app/clarin-wayf/wayf.module.ts @@ -0,0 +1,40 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; + +import { ClarinWayfComponent } from './clarin-wayf.component'; +import { WayfConfig, WAYF_CONFIG, WAYF_DEFAULTS } from './wayf.config'; + +/** + * Angular module wrapper for the WAYF component. + * + * Usage in a host application: + * + * ```ts + * imports: [ + * WayfModule.forRoot({ + * feedUrl: 'https://sp.example.org/Shibboleth.sso/DiscoFeed', + * }), + * ] + * ``` + * + * Only `feedUrl` is required for the standalone widget. + * Optional widget fields fall back to `WAYF_DEFAULTS`. + * Host-only integration fields like `loginEndpoint` may still be supplied + * through the shared `WAYF_CONFIG` token when needed. + */ +@NgModule({ + imports: [ClarinWayfComponent], + exports: [ClarinWayfComponent], +}) +export class WayfModule { + static forRoot(config: Pick & Partial): ModuleWithProviders { + return { + ngModule: WayfModule, + providers: [ + { + provide: WAYF_CONFIG, + useValue: { ...WAYF_DEFAULTS, ...config } as Partial, + }, + ], + }; + } +} diff --git a/src/app/login-page/login-page.component.html b/src/app/login-page/login-page.component.html index cde54f8fd7d..41f4f8b1d09 100644 --- a/src/app/login-page/login-page.component.html +++ b/src/app/login-page/login-page.component.html @@ -1,10 +1,41 @@
- -

{{"login.form.header" | translate}}

+ +

Log in

+ + +
+ + @if (!wayfOpen()) { + + } + @if (wayfOpen()) { +
+
+

Choose your institution

+ +
+ +
+ } +
diff --git a/src/app/login-page/login-page.component.ts b/src/app/login-page/login-page.component.ts index 8835bcb8ce1..b63142c94dc 100644 --- a/src/app/login-page/login-page.component.ts +++ b/src/app/login-page/login-page.component.ts @@ -1,7 +1,9 @@ import { Component, + Inject, OnDestroy, OnInit, + signal, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -29,6 +31,11 @@ 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 { IdentityProvider } from '../clarin-wayf/models/idp-entry.model'; +import { WayfConfig, WAYF_CONFIG } from '../clarin-wayf/wayf.config'; +import { HardRedirectService } from '../core/services/hard-redirect.service'; +import { APP_CONFIG, AppConfig } from '../../config/app-config.interface'; /** * This component represents the login page @@ -40,10 +47,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 +68,14 @@ 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, + @Inject(APP_CONFIG) private appConfig: AppConfig, + @Inject(WAYF_CONFIG) private wayfConfig: WayfConfig) { + } /** * Initialize instance variables @@ -97,4 +115,19 @@ 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: IdentityProvider): void { + // Build the Shibboleth redirect following the LINDAT pattern: + // {spUrl}/Shibboleth.sso/Login?SAMLDS=1&target={restBase}/api/authn/shibboleth?redirectUrl={appUrl}&entityID={idp} + const loginEndpoint = this.wayfConfig.loginEndpoint; + const restBaseUrl = this.appConfig.rest.baseUrl; + const redirectUrl = encodeURIComponent(window.location.origin + '/home'); + const target = `${restBaseUrl}/api/authn/shibboleth?redirectUrl=${redirectUrl}`; + const ssoUrl = `${loginEndpoint}?SAMLDS=1&target=${target}&entityID=${encodeURIComponent(entry.entityID)}`; + 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..24bd3efc56c 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.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, 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..4db7da3d042 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 @@ -4,7 +4,9 @@ import { } from '@angular/common'; import { Component, + Inject, OnInit, + signal, } from '@angular/core'; import { RouterLink, @@ -27,6 +29,8 @@ import { map, } from 'rxjs/operators'; +import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface'; + import { AppState, routerStateSelector, @@ -41,6 +45,10 @@ 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 { IdentityProvider } from '../../clarin-wayf/models/idp-entry.model'; +import { WayfConfig, WAYF_CONFIG } from '../../clarin-wayf/wayf.config'; import { fadeInOut, fadeOut, @@ -59,6 +67,7 @@ import { ThemedUserMenuComponent } from './user-menu/themed-user-menu.component' imports: [ AsyncPipe, BrowserOnlyPipe, + ClarinWayfComponent, NgbDropdownModule, NgClass, RouterLink, @@ -89,9 +98,15 @@ 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, + @Inject(APP_CONFIG) private appConfig: AppConfig, + @Inject(WAYF_CONFIG) private wayfConfig: WayfConfig, ) { this.isMobile$ = this.windowService.isMobile(); } @@ -113,4 +128,20 @@ export class AuthNavMenuComponent implements OnInit { ), ); } + + switchLoginTab(tab: 'local' | 'institution'): void { + this.activeLoginTab.set(tab); + } + + onIdpSelected(entry: IdentityProvider): void { + // Build the Shibboleth redirect following the LINDAT pattern: + // {spUrl}/Shibboleth.sso/Login?SAMLDS=1&target={restBase}/api/authn/shibboleth?redirectUrl={appUrl}&entityID={idp} + const loginEndpoint = this.wayfConfig.loginEndpoint; + const restBaseUrl = this.appConfig.rest.baseUrl; + const currentUrl = this.hardRedirectService.getCurrentRoute(); + const redirectUrl = encodeURIComponent(window.location.origin + currentUrl); + const target = `${restBaseUrl}/api/authn/shibboleth?redirectUrl=${redirectUrl}`; + const ssoUrl = `${loginEndpoint}?SAMLDS=1&target=${target}&entityID=${encodeURIComponent(entry.entityID)}`; + 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..3a7c044bb7e --- /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 { IdentityProvider } 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: IdentityProvider): 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/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,