diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..bfb74a7a9ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,186 @@ +# AGENTS.md — Coding Agent Guidelines for `show-password-login` + +## Purpose + +This file gives a coding agent everything it needs to continue (or complete) the +`show-password-login` feature branch in `mlibrary/dspace-angular` without needing +to ask the human for context. + +--- + +## Repository & Branch + +| Item | Value | +|---|---| +| Repo | `mlibrary/dspace-angular` | +| Active branch | `show-password-login` | +| Base branch | `umich` / `issue-working` | +| Framework | Angular 14 / DSpace 7.6 | +| Language | TypeScript + HTML templates | + +--- + +## The Problem Being Solved + +A UM-specific customisation in `src/app/shared/log-in/log-in.component.html` hard-codes +the password auth method out of the login UI: + +```html +*ngIf="authMethod.authMethodType !== 'password'" +``` + +On the **demo** environment, DSpace OIDC is disabled, making `password` the only advertised +auth method — leaving a completely blank login form. No one can log in to demo. + +Full context: read `PLAN_DSPACE_ANGULAR_PASSWORD_LOGIN.md`. + +--- + +## The Chosen Solution + +A **config-driven exclusive toggle**: a new `showPasswordLogin` boolean in `AuthConfig`. + +- `false` (default) → show OIDC/non-password methods only. Production & workshop unchanged. +- `true` (demo only) → show password form only, hide OIDC. + +Template condition (exclusive — never both, never none): +```html +*ngIf="showPasswordLogin === (authMethod.authMethodType === 'password')" +``` + +--- + +## Work Tracking Files + +| File | Purpose | +|---|---| +| `TODO.md` | Remaining tasks in priority order — work from top to bottom | +| `DONE.md` | Completed tasks — move items here when a task is committed | +| `PLAN_DSPACE_ANGULAR_PASSWORD_LOGIN.md` | Full design rationale and exact code snippets | + +**Always check `TODO.md` first** to see what still needs doing. +**After completing and committing each task**, move it from `TODO.md` to `DONE.md`. + +--- + +## Key Files to Know + +| File | Role | +|---|---| +| `src/config/auth-config.interfaces.ts` | `AuthConfig` interface — add `showPasswordLogin?: boolean` | +| `src/config/default-app-config.ts` | `DefaultAppConfig` class — set `showPasswordLogin: false` in `auth` block | +| `src/app/shared/log-in/log-in.component.ts` | Inject `APP_CONFIG`, read flag, expose as `showPasswordLogin` property | +| `src/app/shared/log-in/log-in.component.html` | Replace `*ngIf` with exclusive-toggle condition | +| `src/app/shared/log-in/log-in.component.spec.ts` | Unit test — needs `APP_CONFIG` provided and assertion updated to `toBe(1)` | +| `src/app/shared/testing/auth-service.stub.ts` | `authMethodsMock` = `[password, shibboleth]` — used by the spec | +| `src/environments/environment.test.ts` | Full `auth:` block used by specs; does NOT have `showPasswordLogin` (omitting it is fine — `undefined ?? false` → `false`) | +| `config/config.example.yml` | Operator reference — add commented-out `showPasswordLogin` entry in the `auth:` block | + +--- + +## How `APP_CONFIG` Is Used in This Codebase + +`APP_CONFIG` is an Angular `InjectionToken` defined in +`src/config/app-config.interface.ts`. Inject it into a component constructor like this: + +```typescript +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; +// Path from src/app/shared/log-in/ → three levels up → src/config/ + +constructor( + // ...existing params... + @Inject(APP_CONFIG) private appConfig: AppConfig +) {} +``` + +Existing examples to reference: +- `src/app/community-list-page/community-list-service.ts` +- `src/app/item-page/edit-item-page/virtual-metadata/virtual-metadata.component.ts` + +In test files, provide it as: +```typescript +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; +// ... +{ provide: APP_CONFIG, useValue: environment } +``` + +`environment` (resolves to `environment.test.ts` during tests) has a full `auth:` block +but no `showPasswordLogin` key. That is fine — the component reads it as +`appConfig.auth?.showPasswordLogin ?? false`, so `undefined ?? false` → `false`. +No change to `environment.test.ts` is required. + +--- + +## Spec File Notes + +`src/app/shared/log-in/log-in.component.spec.ts` currently has: + +```typescript +it('should render a log-in container component for each auth method available', () => { + const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container')); + expect(loginContainers.length).toBe(2); // ← must change to toBe(1) +}); +``` + +`authMethodsMock` contains `[password, shibboleth]`. With `showPasswordLogin: false` (default), +only `shibboleth` renders → `toBe(1)`. The `APP_CONFIG` token must also be added to +`providers` in `TestBed.configureTestingModule` or the component will fail to construct. + +--- + +## `config/config.example.yml` — Operator Documentation + +The `auth:` block in `config/config.example.yml` (around line 91–102) must have a +commented-out `showPasswordLogin` entry added so operators know the option exists: + +```yaml +# When true, show the username/password login form and hide OIDC (exclusive toggle). +# Set to true only on environments where DSpace OIDC is disabled (e.g. demo). +# Default: false (OIDC button shown, password form hidden — production/workshop behaviour). +# Can also be set via environment variable: DSPACE_AUTH_SHOWPASSWORDLOGIN=true +# showPasswordLogin: false +``` + +--- + +## Config Override for Demo (Out-of-Band) + +The demo Kubernetes frontend Deployment/ConfigMap (in `mlibrary/deepblue-documents-kube`, +**not** this repo) must set: + +``` +DSPACE_AUTH_SHOWPASSWORDLOGIN=true +``` + +This task is tracked in `TODO.md` item 9. It is performed separately from this repo's code changes. + +--- + +## Coding Guidelines + +1. **Read `TODO.md` first.** Work tasks in the listed order (they have dependencies). +2. **After completing and committing each task**, update `TODO.md` (mark done / remove) and + add the task to `DONE.md`. +3. **Do not change any file not listed in `TODO.md`** unless fixing a compile/lint error + directly caused by your changes. +4. **After editing each TypeScript file**, run `get_errors` on that file and fix any issues + before moving on. +5. **Run the spec** after updating the spec file: + `npx ng test --include='**/log-in/log-in.component.spec.ts' --watch=false --configuration test --browsers=ChromeHeadless` +6. **Commit message convention:** + `feat: config-driven showPasswordLogin exclusive toggle (DEEPBLUE-466)` +7. The `config/config.yml` in this repo is **not** the demo config — do not modify it. + The demo override happens via environment variable in the Kubernetes repo. +8. Do not remove the ` +*ngIf="authMethod.authMethodType !== 'password'" + + +*ngIf="showPasswordLogin === (authMethod.authMethodType === 'password')" +``` + +#### `src/app/shared/log-in/log-in.component.spec.ts` +- Added `APP_CONFIG` and `environment` imports; provided `APP_CONFIG` in `TestBed`. +- Updated assertion from `toBe(2)` to `toBe(1)` (with `showPasswordLogin: false`, only the + shibboleth method from `authMethodsMock` renders). + +#### `config/config.example.yml` +Added operator-facing documentation in the `auth:` block: +```yaml +# When true, show the username/password login form and hide OIDC (exclusive toggle). +# Set to true only on environments where DSpace OIDC is disabled (e.g. demo). +# Default: false (OIDC button shown, password form hidden — production/workshop behaviour). +# Can also be set via environment variable: DSPACE_AUTH_SHOWPASSWORDLOGIN=true +# showPasswordLogin: false +``` + +--- + +## How to Configure for Demo + +The `config/config.yml` in this repo is **not** the demo config. The demo override is applied +via environment variable in the frontend Kubernetes Deployment/ConfigMap +(in `mlibrary/deepblue-documents-kube`, **not** this repo): + +``` +DSPACE_AUTH_SHOWPASSWORDLOGIN=true +``` + +This follows the DSpace Angular environment-variable naming convention +(see `docs/Configuration.md`): replace `.` with `_`, uppercase, prefix with `DSPACE_`. + +Alternatively, the demo-mounted `config.yml` can include: +```yaml +auth: + showPasswordLogin: true +``` + +**Production and workshop** must NOT set this variable — omitting it leaves the default +`false`, preserving the current OIDC-only login behaviour. + +--- + +## Why This Change Is Safe + +- **Default is `false`** — every environment that doesn't set the flag is unchanged. +- **Production** — no config change → `showPasswordLogin: false` → OIDC button shown, password hidden. ✅ +- **Workshop** — same as production. ✅ +- **Demo** — `DSPACE_AUTH_SHOWPASSWORDLOGIN=true` → password form shown, OIDC button hidden. ✅ +- The `ds-log-in-container` component's `rendersAuthMethodType` decorator handles each auth + method type correctly; the only change is which methods pass the `*ngIf` gate. + +--- + +## How the Auth Methods List Is Populated + +`log-in.component.ts` subscribes to `getAuthenticationMethods` from the NgRx store, which +is populated from the DSpace REST API response at `/api/authn`. The backend returns available +methods as HAL links: + +- `_links.login` → password auth available (`authMethodType: 'password'`) +- `_links.oidcLogin` → OIDC auth available (`authMethodType: 'oidc'`) + +IP auth is explicitly filtered by `log-in.component.ts` before the template sees the list. + +**Demo backend returns** (DSpace OIDC disabled — only `login` link present): +```json +{ + "_links": { + "login": { "href": "https://backend.demo.deepblue-documents.lib.umich.edu/server/api/authn/login" } + } +} +``` +`authMethods` = `[{ authMethodType: 'password' }]`. With `showPasswordLogin: true`, the +password form renders. With the default `false` it would be filtered out (blank form). + +--- + +## Config System Deep-Dive — How the Env-Var Reaches the Component + +This section exists so that if the flag doesn't appear to work, an agent or operator can +trace exactly where the breakdown is. + +### The chain (server-side rendering path) + +1. **`server.ts` (startup):** + ```typescript + const appConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); + extendEnvironmentWithAppConfig(environment, appConfig); + // ... + { provide: APP_CONFIG, useValue: environment } + ``` + `buildAppConfig` reads `DefaultAppConfig`, merges `config/config.yml`, then calls + `overrideWithEnvironment(appConfig)` which walks every property and checks for a matching + `DSPACE_*` env-var. For `auth.showPasswordLogin`, it looks for `DSPACE_AUTH_SHOWPASSWORDLOGIN`. + +2. **`overrideWithEnvironment` (in `src/config/config.server.ts`) — key details:** + - Requires the property to **already exist** in the config object (it only walks existing + properties). Because `DefaultAppConfig` sets `showPasswordLogin: false`, this property + exists and will be found. ✅ + - Checks `isNotEmpty(innerConfig)` before processing. Verified: `isNotEmpty(false) === true`, + so a default of `false` does **not** cause the env-var to be silently ignored. ✅ + - Uses `getBooleanFromString(value)` for boolean properties: + `'true' === 'true' || 'true' === '1'` → actual boolean `true`. ✅ + The component reads a proper boolean, not the string `"true"`. + +3. **`extendEnvironmentWithAppConfig`** mutates the `environment` object in place via `deepmerge`, + so `environment.auth.showPasswordLogin` becomes `true`. + +4. **`APP_CONFIG` token** is provided as `useValue: environment` — the now-extended object. + `LogInComponent` injects it and reads `appConfig.auth?.showPasswordLogin ?? false` → `true`. + +5. **SSR → browser transfer:** `saveAppConfigForCSR()` writes `environment` (already extended) + to Angular's `TransferState`. The browser-side `BrowserInitService` reads it back. The same + `true` value reaches the browser without an additional network round-trip. + +### How to verify the env-var was applied at startup + +The server logs this line when an env-var override fires: +``` +Applying environment variable DSPACE_AUTH_SHOWPASSWORDLOGIN with value true +``` +Check the frontend pod logs immediately after startup. If this line is absent, the env-var +was not set in the environment, or was set with an unexpected key/value. + +### On-host config inspection shortcut + +At startup, `buildAppConfig` writes the final computed config to: +``` +dist/browser/assets/config.json +``` +This is a plain JSON file. After the pod is running, inspect it with: +```shell +kubectl -n demo exec deploy/frontend -- cat /app/dist/browser/assets/config.json | python3 -m json.tool | grep -A2 showPassword +``` +Expected output when correctly configured: +```json +"auth": { + ... + "showPasswordLogin": true +} +``` + +### YAML config alternative — deep-merge safety + +When using `auth.showPasswordLogin: true` in the mounted `config.yml` (rather than the env-var), +`mergeConfig` uses `deepmerge`. The `auth` block is deep-merged, so specifying only +`showPasswordLogin` will not clobber `auth.ui` or `auth.rest`. The full auth block does not +need to be re-specified. ✅ + +--- + +## Remaining Step — Task 9 (different repo) + +In `mlibrary/deepblue-documents-kube`, add to the **frontend** demo Deployment/ConfigMap: +``` +DSPACE_AUTH_SHOWPASSWORDLOGIN=true +``` + +File: `environments/deepblue-documents/demo/` (frontend ConfigMap, not the backend one). + +Confirm production and workshop frontend ConfigMaps do **not** set this variable. + +--- + +## Verification Steps (after demo deployment) + +1. Navigate to `https://demo.deepblue-documents.lib.umich.edu` — U-M WebLogin gate challenges. +2. After passing the gate, click **Log In** in the navbar dropdown. +3. A username/password form should now appear (no OIDC button). +4. Log in using the designated demo DSpace credentials from the restricted-access operations runbook or secret manager reference. +5. Confirm login succeeds and user appears as logged in within the DSpace UI. +6. Log out, then log in using a second designated demo test account from the same restricted-access source to confirm persona switching. + +For workshop/production: login dropdown still shows **only** the OIDC button. No password form. ✅ + +--- + +## Demo Test Accounts + +Demo account identifiers, password values, and password-reset procedures must not be stored in this repository. +Refer to the restricted-access operations runbook / secret manager for current demo credentials and account handling instructions. diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 00000000000..b9fe53b8c0b --- /dev/null +++ b/PULL_REQUEST.md @@ -0,0 +1,108 @@ +# DEEPBLUE-466 — Config-driven password login toggle for demo environment + +## Summary + +Restores the password login form on the **demo** environment without changing +production or workshop behaviour. + +A new `showPasswordLogin` config flag (default `false`) acts as an **exclusive toggle**: + +| `showPasswordLogin` | Login UI | Environment | +|---|---|---| +| `false` (default) | OIDC button only | production, workshop — **no change** | +| `true` | Password form only | demo | + +--- + +## Background + +Deep Blue Documents has three Kubernetes environments: production, workshop, and demo. + +**Demo** uses a two-layer auth model: +1. `oauth2-proxy` gate requires U-M WebLogin (Shibboleth OIDC) — restricts access to U-M people. +2. Inside the gate, users log in to DSpace with **username/password** to switch test personas. + +DSpace's internal OIDC is intentionally disabled for demo so testers aren't auto-logged-in +as their own umich.edu identity. An existing UM customisation (`*ngIf="authMethod.authMethodType !== 'password'"`) removed the password form from the UI — which was fine when OIDC was the +active method but leaves a completely **blank login page** now that OIDC is disabled. + +--- + +## Changes + +### `src/config/auth-config.interfaces.ts` +Added `showPasswordLogin?: boolean` to `AuthConfig`. + +### `src/config/default-app-config.ts` +Defaulted `showPasswordLogin: false` — all environments preserve current behaviour +unless they explicitly opt in. + +### `src/app/shared/log-in/log-in.component.ts` +Injected `APP_CONFIG`; reads flag into `public showPasswordLogin: boolean`. + +### `src/app/shared/log-in/log-in.component.html` +Replaced: +```html +*ngIf="authMethod.authMethodType !== 'password'" +``` +with the exclusive toggle: +```html +*ngIf="showPasswordLogin === (authMethod.authMethodType === 'password')" +``` + +### `src/app/shared/log-in/log-in.component.spec.ts` +Provided `APP_CONFIG` token in `TestBed`; updated `toBe(2)` → `toBe(1)`. + +### `config/config.example.yml` +Documented the new flag (commented out) in the `auth:` block for operators. + +--- + +## Configuration + +Enable for demo via frontend Kubernetes ConfigMap environment variable: +``` +DSPACE_AUTH_SHOWPASSWORDLOGIN=true +``` + +The DSpace Angular config system translates this to a proper boolean `true` +via `getBooleanFromString` (verified). Production and workshop must NOT set +this variable — omitting it is sufficient. + +> ⚠️ **This PR does not touch the Kubernetes config.** Setting the env-var +> in `mlibrary/deepblue-documents-kube` is a separate follow-up step. + +--- + +## Also fixed in this PR + +The `umich` base branch had 14 TypeScript compilation errors in 9 spec files +that prevented the test suite from starting at all. Fixed as part of this PR: + +- `environment.test.ts` — missing `serverLocation` property required by `BuildConfig` +- `item-withdraw.component.spec.ts` / `item-reinstate.component.spec.ts` — `setWithDrawn` now requires a `reason` argument (TS2554) +- 6 bitstream-format spec files — `BitstreamFormatSupportLevel` enum values renamed: `Unknown → AS_IS_UNKNOWN`, `Known → AS_IS_KNOWN`, `Supported → HIGHEST_LEVEL` + +--- + +## Testing + +- ✅ Unit tests: `log-in.component.spec.ts` — 2 specs, 0 failures +- ✅ Config flow verified end-to-end: env-var → `buildAppConfig` → `extendEnvironmentWithAppConfig` → `APP_CONFIG` token → component → template +- ✅ `isNotEmpty(false) === true` confirmed — the env-var is **not** silently ignored when the default value is `false` + +See `PLAN_DSPACE_ANGULAR_PASSWORD_LOGIN.md` for full rationale, config system +deep-dive, on-host debugging commands, and post-deployment verification steps. + +--- + +## Verification after demo deployment + +1. Navigate to `https://demo.deepblue-documents.lib.umich.edu` — gate challenges for U-M WebLogin. +2. After passing the gate, click **Log In**. +3. A username/password form appears (no OIDC button). ✅ +4. Log in as `dbrrds@umich.edu`. Confirm login succeeds. ✅ +5. Log out; log in as the alternate demo persona (for example `tildon@umich.edu`) using credentials retrieved from the approved secret manager / restricted ops runbook referenced in `PLAN_DSPACE_ANGULAR_PASSWORD_LOGIN.md` — confirm persona switching works. ✅ + +For **production/workshop**: login still shows only the OIDC button. No regression. ✅ + diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000000..4fc02419841 --- /dev/null +++ b/TODO.md @@ -0,0 +1,16 @@ +# TODO — show-password-login implementation + +Tasks are listed in dependency order. Complete them top-to-bottom. +Move each task to `DONE.md` when it is committed to the `show-password-login` branch. + +--- + +## 9. Note in Kubernetes repo (out-of-band — different repository) + +This task is tracked here for completeness; it is performed in `mlibrary/deepblue-documents-kube`, +not in this repo. + +- [ ] Add `DSPACE_AUTH_SHOWPASSWORDLOGIN=true` to the **frontend** demo Deployment/ConfigMap + in `environments/deepblue-documents/demo/`. +- [ ] Confirm production and workshop frontend ConfigMaps do **not** set this variable + (they default to `false`). diff --git a/config/config.example.yml b/config/config.example.yml index d8f8929e56d..a09a46b865b 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -75,7 +75,7 @@ cache: anonymousCache: # Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled. # As all pages are cached in server memory, increasing this value will increase memory needs. - # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. + # Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory. max: 0 # Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached # copy is automatically refreshed on the next request. @@ -100,6 +100,11 @@ auth: # If the rest token expires in less than this amount of time, it will be refreshed automatically. # This is independent from the idle warning. timeLeftBeforeTokenRefresh: 120000 # 2 minutes + # When true, show only the username/password login form and hide all non-password authentication methods. + # Set to true only in environments where password login should be the only login option (e.g. demo). + # Default: false (non-password authentication methods such as OIDC/LDAP/X509 remain available, password form hidden). + # Can also be set via environment variable: DSPACE_AUTH_SHOWPASSWORDLOGIN=true + # showPasswordLogin: false # Form settings form: @@ -379,7 +384,7 @@ vocabularies: vocabulary: 'srsc' enabled: true -# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. +# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. comcolSelectionSort: sortField: 'dc.title' sortDirection: 'ASC' diff --git a/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts index 6787350d865..5485425e5c8 100644 --- a/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.spec.ts @@ -25,7 +25,7 @@ describe('AddBitstreamFormatComponent', () => { bitstreamFormat.shortDescription = 'Unknown'; bitstreamFormat.description = 'Unknown data format'; bitstreamFormat.mimetype = 'application/octet-stream'; - bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.AS_IS_UNKNOWN; bitstreamFormat.internal = false; bitstreamFormat.extensions = null; diff --git a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index 8a44240b7e2..05f10f69f76 100644 --- a/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -41,7 +41,7 @@ describe('BitstreamFormatsComponent', () => { bitstreamFormat1.shortDescription = 'Unknown'; bitstreamFormat1.description = 'Unknown data format'; bitstreamFormat1.mimetype = 'application/octet-stream'; - bitstreamFormat1.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat1.supportLevel = BitstreamFormatSupportLevel.AS_IS_UNKNOWN; bitstreamFormat1.internal = false; bitstreamFormat1.extensions = null; @@ -51,7 +51,7 @@ describe('BitstreamFormatsComponent', () => { bitstreamFormat2.shortDescription = 'License'; bitstreamFormat2.description = 'Item-specific license agreed upon to submission'; bitstreamFormat2.mimetype = 'text/plain; charset=utf-8'; - bitstreamFormat2.supportLevel = BitstreamFormatSupportLevel.Known; + bitstreamFormat2.supportLevel = BitstreamFormatSupportLevel.AS_IS_KNOWN; bitstreamFormat2.internal = true; bitstreamFormat2.extensions = null; @@ -61,7 +61,7 @@ describe('BitstreamFormatsComponent', () => { bitstreamFormat3.shortDescription = 'CC License'; bitstreamFormat3.description = 'Item-specific Creative Commons license agreed upon to submission'; bitstreamFormat3.mimetype = 'text/html; charset=utf-8'; - bitstreamFormat3.supportLevel = BitstreamFormatSupportLevel.Supported; + bitstreamFormat3.supportLevel = BitstreamFormatSupportLevel.HIGHEST_LEVEL; bitstreamFormat3.internal = true; bitstreamFormat3.extensions = null; @@ -71,7 +71,7 @@ describe('BitstreamFormatsComponent', () => { bitstreamFormat4.shortDescription = 'Adobe PDF'; bitstreamFormat4.description = 'Adobe Portable Document Format'; bitstreamFormat4.mimetype = 'application/pdf'; - bitstreamFormat4.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat4.supportLevel = BitstreamFormatSupportLevel.AS_IS_UNKNOWN; bitstreamFormat4.internal = false; bitstreamFormat4.extensions = null; diff --git a/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts index b09c50c70ad..f804817a934 100644 --- a/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.spec.ts @@ -30,7 +30,7 @@ describe('EditBitstreamFormatComponent', () => { bitstreamFormat.shortDescription = 'Unknown'; bitstreamFormat.description = 'Unknown data format'; bitstreamFormat.mimetype = 'application/octet-stream'; - bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.AS_IS_UNKNOWN; bitstreamFormat.internal = false; bitstreamFormat.extensions = null; diff --git a/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts b/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts index ca3fbcbc99a..46afd6eb2c3 100644 --- a/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts +++ b/src/app/admin/admin-registries/bitstream-formats/format-form/format-form.component.spec.ts @@ -25,7 +25,7 @@ describe('FormatFormComponent', () => { bitstreamFormat.shortDescription = 'Unknown'; bitstreamFormat.description = 'Unknown data format'; bitstreamFormat.mimetype = 'application/octet-stream'; - bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.Unknown; + bitstreamFormat.supportLevel = BitstreamFormatSupportLevel.AS_IS_UNKNOWN; bitstreamFormat.internal = false; bitstreamFormat.extensions = []; diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts index b83f2b96643..a51ec8b68fd 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts @@ -59,7 +59,7 @@ describe('EditBitstreamPageComponent', () => { id: '1', shortDescription: 'Unknown', description: 'Unknown format', - supportLevel: BitstreamFormatSupportLevel.Unknown, + supportLevel: BitstreamFormatSupportLevel.AS_IS_UNKNOWN, mimetype: 'application/octet-stream', _links: { self: { href: 'format-selflink-1' } @@ -69,7 +69,7 @@ describe('EditBitstreamPageComponent', () => { id: '2', shortDescription: 'PNG', description: 'Portable Network Graphics', - supportLevel: BitstreamFormatSupportLevel.Known, + supportLevel: BitstreamFormatSupportLevel.AS_IS_KNOWN, mimetype: 'image/png', _links: { self: { href: 'format-selflink-2' } @@ -79,7 +79,7 @@ describe('EditBitstreamPageComponent', () => { id: '3', shortDescription: 'GIF', description: 'Graphics Interchange Format', - supportLevel: BitstreamFormatSupportLevel.Known, + supportLevel: BitstreamFormatSupportLevel.AS_IS_KNOWN, mimetype: 'image/gif', _links: { self: { href: 'format-selflink-3' } diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index 89178f8dd21..45dfb8a68de 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -49,7 +49,7 @@ describe('BitstreamDataService', () => { id: '2', shortDescription: 'PNG', description: 'Portable Network Graphics', - supportLevel: BitstreamFormatSupportLevel.Known + supportLevel: BitstreamFormatSupportLevel.AS_IS_KNOWN }); const url = 'fake-bitstream-url'; diff --git a/src/app/item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts b/src/app/item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts index 594e7b806a7..849adefa368 100644 --- a/src/app/item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-reinstate/item-reinstate.component.spec.ts @@ -91,7 +91,7 @@ describe('ItemReinstateComponent', () => { spyOn(comp, 'processRestResponse'); comp.performAction(); - expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(comp.item, false); + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(comp.item, false, ''); expect(comp.processRestResponse).toHaveBeenCalled(); }); }); diff --git a/src/app/item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts b/src/app/item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts index 3397742f5b0..db42d6dc91f 100644 --- a/src/app/item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-withdraw/item-withdraw.component.spec.ts @@ -87,9 +87,11 @@ describe('ItemWithdrawComponent', () => { describe('performAction', () => { it('should call setWithdrawn function from the ItemDataService', () => { spyOn(comp, 'processRestResponse'); + const getElementSpy = spyOn(document, 'getElementById').and.returnValue({ value: 'test-withdraw-reason' } as unknown as HTMLInputElement); comp.performAction(); - expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem, true); + expect(getElementSpy).toHaveBeenCalledWith('withdrawReason'); + expect(mockItemDataService.setWithDrawn).toHaveBeenCalledWith(mockItem, true, 'test-withdraw-reason'); expect(comp.processRestResponse).toHaveBeenCalled(); }); }); diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 718c4d7fbd9..b25f58646cd 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,9 +1,9 @@
- diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index 0a13e8b701e..e3c797b71b9 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -11,6 +11,9 @@ import { AuthService } from '../../core/auth/auth.service'; import { authMethodsMock, AuthServiceStub } from '../testing/auth-service.stub'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { SharedModule } from '../shared.module'; +import { LogInContainerComponent } from './container/log-in-container.component'; +import { LogInPasswordComponent } from './methods/password/log-in-password.component'; +import { LogInExternalProviderComponent } from './methods/log-in-external-provider/log-in-external-provider.component'; import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; import { ActivatedRouteStub } from '../testing/active-router.stub'; import { ActivatedRoute } from '@angular/router'; @@ -21,6 +24,8 @@ import { RouterTestingModule } from '@angular/router/testing'; import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { of } from 'rxjs'; +import { APP_CONFIG } from '../../../config/app-config.interface'; +import { environment } from '../../../environments/environment'; describe('LogInComponent', () => { @@ -74,6 +79,7 @@ describe('LogInComponent', () => { { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: HardRedirectService, useValue: hardRedirectService }, { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: APP_CONFIG, useValue: environment }, provideMockStore({ initialState }), LogInComponent ], @@ -121,11 +127,36 @@ describe('LogInComponent', () => { component = null; }); - it('should render a log-in container component for each auth method available', () => { - const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container')); - expect(loginContainers.length).toBe(2); + it('should render only non-password auth methods when showPasswordLogin is false (default)', () => { + const loginContainers = fixture.debugElement.queryAll(By.directive(LogInContainerComponent)); + // authMethodsMock = [password, shibboleth]. With showPasswordLogin: false (default), + // only the shibboleth container renders (exclusive toggle). The outer container count + // and the inner rendered method component both confirm which method is shown. + expect(loginContainers.length).toBe(1); + expect(loginContainers[0].componentInstance.authMethod.authMethodType).toBe('shibboleth'); + // The external-provider inner component is loaded via ngComponentOutlet for shibboleth. + expect(fixture.debugElement.queryAll(By.directive(LogInExternalProviderComponent)).length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(LogInPasswordComponent)).length).toBe(0); + }); + it('should render only the password auth method when showPasswordLogin is enabled', () => { + // TestBed.overrideProvider() cannot be called after the module is instantiated, so we + // set the property directly on the component instance instead of going through APP_CONFIG. + component.showPasswordLogin = true; + fixture.detectChanges(); // re-render LogInComponent; creates the password LogInContainerComponent + fixture.detectChanges(); // second cycle allows ngComponentOutlet inside the new container to render LogInPasswordComponent + + const loginContainers = fixture.debugElement.queryAll(By.directive(LogInContainerComponent)); + // authMethodsMock = [password, shibboleth]. With showPasswordLogin: true, + // only the password container renders (exclusive toggle). The outer container count + // and the inner rendered method component both confirm which method is shown. + expect(loginContainers.length).toBe(1); + // The password inner component is loaded via ngComponentOutlet for password. + expect(fixture.debugElement.queryAll(By.directive(LogInPasswordComponent)).length).toBe(1); + expect(fixture.debugElement.queryAll(By.directive(LogInExternalProviderComponent)).length).toBe(0); }); + + }); }); diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index e6b2ba9dcf1..79dff4cb6e3 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { AuthMethod } from '../../core/auth/models/auth.method'; @@ -15,6 +15,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { CoreState } from '../../core/core-state.model'; import { AuthMethodType } from '../../core/auth/models/auth.method-type'; +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; /** * /users/sign-in @@ -56,12 +57,20 @@ export class LogInComponent implements OnInit { */ canRegister$: Observable; + /** + * Whether the username/password login form should be shown (exclusive toggle). + * Driven by config.auth.showPasswordLogin. Defaults to false. + */ + public showPasswordLogin: boolean; + constructor(private store: Store, private authService: AuthService, - private authorizationService: AuthorizationDataService) { + private authorizationService: AuthorizationDataService, + @Inject(APP_CONFIG) private appConfig: AppConfig) { } ngOnInit(): void { + this.showPasswordLogin = this.appConfig.auth?.showPasswordLogin ?? false; this.store.pipe( select(getAuthenticationMethods), diff --git a/src/config/auth-config.interfaces.ts b/src/config/auth-config.interfaces.ts index 59ebda12da3..734c92d7df2 100644 --- a/src/config/auth-config.interfaces.ts +++ b/src/config/auth-config.interfaces.ts @@ -20,4 +20,10 @@ export interface AuthConfig extends Config { // This is independent from the idle warning. timeLeftBeforeTokenRefresh: number; }; + + // When true, the username/password login form is rendered and non-password authentication methods are hidden. + // Set to false (default) to show non-password authentication methods instead of the password form + // (for example OIDC in production/workshop environments). + // Set to true for environments where password auth is required and non-password methods should not be shown (demo). + showPasswordLogin?: boolean; } diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts index 6f4ecc83b3c..38ccf843aa3 100644 --- a/src/config/default-app-config.ts +++ b/src/config/default-app-config.ts @@ -115,7 +115,8 @@ export class DefaultAppConfig implements AppConfig { // If the rest token expires in less than this amount of time, it will be refreshed automatically. // This is independent from the idle warning. timeLeftBeforeTokenRefresh: 2 * 60 * 1000 // 2 minutes - } + }, + showPasswordLogin: false, }; // Form settings diff --git a/src/environments/environment.test.ts b/src/environments/environment.test.ts index 3df2f6e2361..661dd323e5e 100644 --- a/src/environments/environment.test.ts +++ b/src/environments/environment.test.ts @@ -6,6 +6,8 @@ import { NotificationAnimationsType } from '../app/shared/notifications/models/n export const environment: BuildConfig = { production: false, + serverLocation: 'http://localhost:8080/server', + // Angular Universal settings universal: { preboot: true,