Compare commits

..
22 Commits
Author SHA1 Message Date
Diogo Bastos 9667cc6d6f AAE-48166 Sync time using server time 2026-07-08 12:39:53 +01:00
Diogo Bastos 543770d0fd Add Flag 2026-07-06 16:33:54 +01:00
Diogo Bastos 1e4a3d4154 WIP 2026-07-06 12:47:07 +01:00
copilot-swe-agent[bot] 90ebb19745 Fix startPeriodicSync call to match updated signature 2026-07-04 09:51:28 +00:00
copilot-swe-agent[bot] 264fc90e67 Remove maxAllowedOffsetMs from TimeSyncService 2026-07-03 20:25:58 +00:00
copilot-swe-agent[bot] 41c0bdc346 fix: resync clock before invalidating tokens at startup
At startup, when the first OAuth event fires, the service checks if the
access token is valid. Previously it would immediately clear storage if
the token appeared invalid. This could cause false negatives when the
local clock was out of sync (e.g. VM/Citrix environments).

Now the service first re-syncs the clock offset via TimeSyncService, then
re-evaluates the token validity. Storage is only cleared if the token is
still invalid after the corrected time check.
2026-07-03 17:27:39 +00:00
copilot-swe-agent[bot] 3424c53da5 fix(core): provide meaningful error message when API response body is null
When the server returns no response body (e.g., during token expiration,
network interruption, or CORS failures), err.error is null causing
JSON.stringify(null) to produce the unhelpful message "null".

Now we explicitly handle the null case and produce a message like
"401 Unauthorized" instead of "null".
2026-07-03 16:47:38 +00:00
copilot-swe-agent[bot] 055600a43b fix(auth): reset tokenRefreshErrorCount on successful token event
The tokenRefreshErrorCount in the oauthErrorEventOccurDueToClockOutOfSync$
stream now resets to 0 when a 'token_received' or 'token_refreshed' event
occurs. This prevents intermittent errors that self-correct from
accumulating toward the clock-drift detection threshold.
2026-07-03 15:16:45 +00:00
copilot-swe-agent[bot] 39eba35ffb refactor(auth): replace IAM header interceptor with HEAD request to app root for time sync
Instead of passively reading the Date header from IAM responses (which
requires IAM to be reachable and has CORS constraints), the TimeSyncService
now makes a lightweight HEAD request to the application's own root URL
(served by nginx). This is simpler and more reliable because:

- No CORS issues (same origin)
- Nginx always includes a Date header
- HEAD request has minimal payload (no body)
- Works before authentication is established
- No dedicated serverTimeUrl configuration needed

The DateHeaderTimeSyncInterceptor has been removed as it is no longer needed.
2026-07-03 13:41:58 +00:00
copilot-swe-agent[bot] 078c921d27 fix(auth): check token validity after clock re-sync before attempting refresh
After re-syncing the clock offset, the code now checks whether the token
is actually still valid using the corrected time (via tokenHasExpired()).
If the token is valid, no refresh is needed — the token only appeared
expired due to clock drift. This avoids the impossible situation of
retrying a token refresh when the triggering error was itself a refresh
error.
2026-07-03 13:16:28 +00:00
copilot-swe-agent[bot] dc214beb29 refactor: remove tokenHasExpiredDueToClockOutOfSync$ and re-sync clock + refresh token on validation failure
Instead of having a separate observable that detects token expiration due to clock
drift and immediately logs the user out, the oauthErrorEventOccurDueToClockOutOfSync$
now re-syncs the clock offset and attempts a token refresh when clock drift is detected.
Only if the refresh fails after re-syncing does it propagate the error and trigger logout.
2026-07-03 11:45:21 +00:00
copilot-swe-agent[bot] 31d7396c71 fix(core): limit DateHeaderTimeSyncInterceptor to IAM API responses only 2026-07-03 11:24:18 +00:00
copilot-swe-agent[bot] 958f8c908e fix(core): treat empty/whitespace serverTimeUrl as not configured in TimeSyncService 2026-07-03 09:57:45 +00:00
copilot-swe-agent[bot] aafcf5ce76 fix(core): remove duplicate @angular/common/http import in date-header-time-sync interceptor spec 2026-07-03 07:35:00 +00:00
Eugenio Romano 28ef893ac2 Merge branch 'develop' into copilot/explain-time-url-authentication 2026-07-03 05:12:15 +02:00
copilot-swe-agent[bot] dae0b077f8 feat: retry token refresh before checking for clock out-of-sync
- tokenHasExpiredDueToClockOutOfSync$: skip the first "token expired" event
  so the library's automatic refresh has a chance to run before clock drift
  is diagnosed as the root cause
- oauthErrorEventOccurDueToClockOutOfSync$: use scan() to skip the first
  token_refresh_error (allowing one retry) and only check clock sync on the
  second occurrence; all other error types are still checked immediately
- Update test for token expiry: now requires two events before logout
- Replace single token_refresh_error clock-sync test with two tests:
  one verifying no logout on first error, one verifying logout on second
2026-07-03 03:05:42 +00:00
copilot-swe-agent[bot] 67658fc3ba feat: replace dedicated time API with passive Date response header interception
- Add `updateClockOffsetFromDateHeader()` to `TimeSyncService` so the clock
  offset can be updated from any HTTP `Date` response header (RFC 7231)
- Make `serverTimeUrl` config optional: `syncClockOffset` is a safe no-op and
  `checkTimeSync` uses the stored `clockOffsetMs` when no URL is configured
- Add `DateHeaderTimeSyncInterceptor` that reads the `Date` header from every
  HTTP response and passively keeps `TimeSyncService.clockOffsetMs` current
- Register the new interceptor in `provideCoreAuth()` / `AuthModule`
- Export `DateHeaderTimeSyncInterceptor` from the public API
- Add unit tests for all new paths
2026-07-03 01:55:32 +00:00
copilot-swe-agent[bot] 8bb67f2729 feat(auth): add periodic clock re-sync to protect against mid-session drift in Citrix/VM environments
- Add startPeriodicSync() and stopPeriodicSync() to TimeSyncService
- Re-sync clock offset every 5 minutes and on document visibility change
- Add security cap (maxAllowedOffsetMs) to reject unreasonably large offsets
- Preserve existing offset on sync failures instead of resetting to 0
- Wire periodic sync start in RedirectAuthService.configureAuth()
- Wire periodic sync stop in RedirectAuthService.logout()
- Add unit tests for periodic sync and offset cap behavior
2026-07-02 16:01:49 +00:00
copilot-swe-agent[bot] 4c2de33cf1 feat(core/auth): add TimeSyncDateTimeProvider for angular-oauth2-oidc clock drift correction 2026-07-02 09:48:35 +00:00
Eugenio RomanoandCopilot Autofix powered by AI c9d1517f45 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-01 23:11:15 +02:00
Eugenio RomanoandCopilot Autofix powered by AI 9214bf357f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-01 23:10:10 +02:00
copilot-swe-agent[bot] 2fa592eccb fix: compensate for VM/Citrix clock drift in token expiry check 2026-07-01 20:07:19 +00:00
233 changed files with 5420 additions and 3939 deletions
+2 -7
View File
@@ -12,14 +12,9 @@ runs:
- name: base vars
shell: bash
run: |
if [ -n "${GITHUB_BASE_REF:-}" ]; then
BASE_HASH=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || git rev-parse HEAD)
else
BASE_HASH=$(git rev-parse HEAD)
fi
{
echo "GIT_HASH=$(git rev-parse HEAD)";
echo "BASE_HASH=${BASE_HASH}";
echo "BASE_HASH=$(git merge-base origin/${GITHUB_BASE_REF} HEAD)";
echo "HEAD_HASH=HEAD";
echo "HEAD_COMMIT_HASH=${GH_COMMIT}";
echo "NX_CALCULATION_FLAGS=--all";
@@ -60,7 +55,7 @@ runs:
} >> $GITHUB_ENV
- name: RELEASE on master/develop patch branch
if: ${{ env.BREAK_ACTION == false && (github.event.pull_request.merged || github.event_name == 'push') }}
if: ${{ env.BREAK_ACTION == false && github.event.pull_request.merged }}
shell: bash
env:
REF_NAME: ${{ github.ref_name }}
+2 -2
View File
@@ -23,8 +23,8 @@ runs:
env:
DRY_RUN_FLAG: ${{ inputs.dry-run-flag }}
run: |
if [[ "$DRY_RUN_FLAG" == 'true' ]]; then
echo "dryrun=--dry-run" >> $GITHUB_OUTPUT;
if [[ '$DRY_RUN_FLAG' == 'true' ]]; then
echo "dryrun=--dryrun" >> $GITHUB_OUTPUT;
echo "enabling dryrun"
else
echo "dryrun=" >> $GITHUB_OUTPUT;
+2 -2
View File
@@ -28,14 +28,14 @@ runs:
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: get latest tag sha
if: ${{ inputs.full-setup == 'true' }}
id: tag-sha
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
uses: Alfresco/alfresco-build-tools/.github/actions/git-latest-tag@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
- name: load "NPM TAG"
if: ${{ inputs.full-setup == 'true' }}
id: set-npm-tag
+3 -3
View File
@@ -30,7 +30,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.29.5
# Override language selection by uncommenting this and choosing your languages
with:
languages: javascript
@@ -39,7 +39,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.29.5
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -53,4 +53,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v3.29.5
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.29.5
+1 -10
View File
@@ -8,22 +8,13 @@ jobs:
runs-on: ubuntu-latest
if: github.event.registry_package.package_type == 'npm' && github.event.registry_package.name == 'adf-core'
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.GH_APP_ENGINEERING_CONTRIB_CLIENT_ID }}
private-key: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: alfresco-apps
permission-contents: write
- name: Dispatch event to monorepo
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PACKAGE_NAME: ${{ github.event.registry_package.name }}
PACKAGE_VERSION: ${{ github.event.registry_package.package_version.name }}
with:
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ secrets.PAT_WRITE_PKG }}
retries: 3
script: |
const payload = {
+4 -12
View File
@@ -5,7 +5,7 @@ on:
workflow_dispatch:
workflow_call:
secrets:
GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY:
BOT_GITHUB_TOKEN:
required: true
CROWDIN_TRANSLATIONS_TOKEN:
required: true
@@ -15,21 +15,13 @@ jobs:
pull-from-crowdin:
runs-on: ubuntu-latest
steps:
- name: Generate app token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.GH_APP_ENGINEERING_CONTRIB_CLIENT_ID }}
private-key: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: develop
token: ${{ steps.app-token.outputs.token }}
token: ${{ secrets.BOT_GITHUB_TOKEN }}
- name: Pull translations from Crowdin
uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
skip_ref_checkout: true
upload_sources: false
@@ -42,5 +34,5 @@ jobs:
github_user_email: ${{ vars.HXPS_GIT_EMAIL }}
gpg_private_key: ${{ secrets.HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY }}
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TRANSLATIONS_TOKEN }}
+22 -10
View File
@@ -1,6 +1,13 @@
name: "release"
on:
workflow_call:
inputs:
dry-run-flag:
description: 'enable dry-run on artifact push'
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
dry-run-flag:
@@ -8,10 +15,13 @@ on:
required: false
type: boolean
default: true
push:
pull_request:
types: [closed]
branches:
- develop
- develop-patch*
push:
branches:
- master
- master-patch-*
@@ -24,6 +34,8 @@ concurrency:
cancel-in-progress: false
env:
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
GH_COMMIT: ${{ github.sha }}
GH_BUILD_NUMBER: ${{ github.run_id }}
LOG_LEVEL: "ERROR"
@@ -32,7 +44,7 @@ env:
jobs:
setup:
timeout-minutes: 20
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
name: "Setup"
runs-on: ubuntu-latest
permissions:
@@ -56,7 +68,7 @@ jobs:
outputs:
release_version: ${{ steps.set-version.outputs.release_version }}
timeout-minutes: 30
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
@@ -90,22 +102,22 @@ jobs:
run: |
pnpm build:libs
pnpm build:schematics
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
name: release libraries GH registry
with:
node-version-file: '.nvmrc'
registry-url: 'https://npm.pkg.github.com'
scope: '@alfresco'
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} --provenance=false ${{ steps.set-dryrun.outputs.dryrun }}
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} --provenance=false
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
name: release libraries Npm registry
with:
node-version-file: '.nvmrc'
registry-url: 'https://${{ vars.NPM_REGISTRY_ADDRESS }}'
scope: '@alfresco'
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }} ${{ steps.set-dryrun.outputs.dryrun }}
- run: pnpm run publish --tag ${{ steps.setup.outputs.npm-tag }}
create-git-tag:
runs-on: ubuntu-latest
@@ -126,7 +138,7 @@ jobs:
npm-check-bundle:
needs: [release-npm]
timeout-minutes: 15
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
if: github.event.pull_request.merged == true || github.ref_name == 'master' || github.ref_name == 'master-patch-*' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -147,7 +159,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Push Source Files to Crowdin
uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_sources: true
upload_sources_args: --delete-obsolete
@@ -159,7 +171,7 @@ jobs:
name: Run Crowdin pull pipeline for up-to-date sync
uses: ./.github/workflows/pull-from-crowdin.yml
secrets:
GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY: ${{ secrets.GH_APP_ENGINEERING_CONTRIB_PRIVATE_KEY }}
BOT_GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
CROWDIN_TRANSLATIONS_TOKEN: ${{ secrets.CROWDIN_TRANSLATIONS_TOKEN }}
HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY: ${{ secrets.HXPS_GIT_COMMIT_SIGNING_PRIVATE_KEY }}
+1 -1
View File
@@ -11,4 +11,4 @@ permissions:
jobs:
stale-pr-cleanup:
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
uses: Alfresco/alfresco-build-tools/.github/workflows/stale-pr-cleanup.yml@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
@@ -16,7 +16,7 @@ jobs:
permissions:
pull-requests: write
steps:
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@1d671f8f10336861c89c67be57c6648d98876103 # v18.19.0
- uses: Alfresco/alfresco-build-tools/.github/actions/github-upsert-comment@10cea6c0b390c4f8af21d9dc1670d581bd10319f # v18.13.0
with:
comment-identifier: supply-chain-review-instructions
comment-body: |
+9 -9
View File
@@ -39,9 +39,9 @@
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
# - github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7
@@ -123,7 +123,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -483,7 +483,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1125,7 +1125,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1390,7 +1390,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1486,7 +1486,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
@@ -1633,7 +1633,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1710,7 +1710,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@23fd0ef1f0a76a3707df928f9c733abd98e4ec75 # v0.82.12
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+2 -9
View File
@@ -351,6 +351,7 @@ for more information about installing and using the source code.
| Name | Description | Source link |
| ---- | ----------- | ----------- |
| [Agent Service](content-services/services/agent.service.md) | Manages agents in Content Services. | [Source](../lib/content-services/src/lib/agent/services/agent.service.ts) |
| [Audit Service](content-services/services/audit.service.md) | Manages Audit apps and entries. | [Source](../lib/content-services/src/lib/audit/audit.service.ts) |
| [Card View Content Update Service](content-services/services/card-view-content-update.service.md) | Manages Card View properties in the content services environment. Implements BaseCardViewContentUpdate. | [Source](../lib/content-services/src/lib/common/services/card-view-content-update.service.ts) | |
| [Category tree datasource service](content-services/services/category-tree-datasource.service.md) | Datasource service for category tree. | [Source](../lib/content-services/src/lib/category/services/category-tree-datasource.service.ts) |
@@ -366,6 +367,7 @@ for more information about installing and using the source code.
| [Node Comments Service](content-services/services/node-comments.service.md) | Adds and retrieves comments for nodes in Content Services. | [Source](../lib/content-services/src/lib/node-comments/services/node-comments.service.ts) |
| [Node permission dialog service](content-services/services/node-permission-dialog.service.md) | Displays dialogs to let the user set node permissions. | [Source](../lib/content-services/src/lib/permission-manager/services/node-permission-dialog.service.ts) |
| [Node Permission service](content-services/services/node-permission.service.md) | Manages role permissions for content nodes. | [Source](../lib/content-services/src/lib/permission-manager/services/node-permission.service.ts) |
| [Search Ai Service](content-services/services/search-ai.service.md) | Manages search AI in Content Services. | [Source](../lib/content-services/src/lib/search-ai/services/search-ai.service.ts) |
| [Search filter service](content-services/services/search-filter.service.md) | Registers widgets for use with the Search Filter component. | [Source](../lib/content-services/src/lib/search/services/search-filter.service.ts) |
| [Search Query Builder service](content-services/services/search-query-builder.service.md) | Stores information from all the custom search and faceted search widgets, compiles and runs the final search query. | [Source](../lib/content-services/src/lib/search/services/search-query-builder.service.ts) |
| [Security Controls service](content-services/services/security-controls.service.md) | Manages security groups & marks in Content Services. | [Source](../lib/content-services/src/lib/security/services/security-controls-groups-marks-security.service.ts) |
@@ -565,12 +567,3 @@ Contains all custom rules used by ESLint.
<!--eslint-angular end-->
[(Back to Contents)](#contents)
## Notes
### Knowledge Discovery removal (v9.0.0)
The Knowledge Discovery components, services, and APIs have been removed as of version 9.0.0.
The integrated Knowledge Discovery features have been replaced with a section in the side navigation panel containing a link to the standalone Knowledge Discovery UI application in higher level apps such as ACA and ADW.
If you were using those features, please consider cleaning Local Storage in your browser as *aiReferences* entry may remain.
@@ -0,0 +1,27 @@
---
Title: Agent service
Added: v7.0.0-alpha.3
Status: Active
Last reviewed: 2024-07-12
---
# [Agent service](../../../lib/content-services/src/lib/agent/services/agent.service.ts "Defined in agent.service.ts")
Manages agents in Content Services.<br/>
<b>In order to use this service, you need to have the HX Insights Connector (additional ACS module) installed.</b>
## Class members
### Methods
- **getAgents**(): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AgentPaging`](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md#agentpaging)`>`<br/>
Gets all agents.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AgentPaging`](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md#agentpaging)`>` - AgentPaging object containing the agents.
## Details
See the
[Agents API](../../../lib/js-api/src/api/content-rest-api/docs/AgentsApi.md) for more information about the types returned by [Agent
service](agent.service.md) methods and for the implementation of the REST API the service is
based on.
@@ -23,12 +23,11 @@ Manages Document List information that is specific to a user.
- _node:_ `any` - Node object
- _nodeId:_ `string` - ID of the node object
- **Returns** `string` - ID value
- **getRecentFiles**(personId: `string`, pagination: [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts), filters?: `string[]`, includeFields: `string[]` = `[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>`<br/>
- **getRecentFiles**(personId: `string`, pagination: [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts), filters?: `string[]`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>`<br/>
Gets files recently accessed by a user.
- _personId:_ `string` - ID of the user
- _pagination:_ [`PaginationModel`](../../../lib/core/src/lib/models/pagination.model.ts) - Specifies how to paginate the results
- _filters:_ `string[]` - (Optional) Specifies additional filters to apply (joined with **AND**)
- _includeFields:_ `string[]` - List of data field names to include in the results
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>` - List of nodes for the recently used files
- **hasCorrespondingNodeIds**(nodeId: `string`): `boolean`<br/>
Does the well-known alias have a corresponding node ID?
@@ -0,0 +1,43 @@
---
Title: Search Ai service
Added: v7.0.0-alpha.3
Status: Active
Last reviewed: 2024-07-12
---
# [Search Ai service](../../../lib/content-services/src/lib/search-ai/services/search-ai.service.ts "Defined in search-ai.service.ts")
Manages search AI in Content Services.
<b>In order to use this service, you need to have the HX Insights Connector (additional ACS module) installed.</b>
## Class members
### Methods
- **updateSearchAiInputState**(state: `SearchAiInputState`): `void`<br/>
Update the state of the search AI input.
- _state:_ `SearchAiInputState` - The new state of the search AI input.
- **ask**(question: [`QuestionRequest`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionrequest)): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`QuestionModel`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionmodel)`>`<br/>
Ask a question to the AI.
- _question:_ [`QuestionRequest`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionrequest) - The question to ask.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`QuestionModel`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#questionmodel)`>` - QuestionModel object containing information about questions.
- **getAnswer**(questionId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AiAnswerEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#aianswerentry)`>`<br/>
Get an answer to specific question.
- _questionId:_ `string` - The ID of the question to get an answer for.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`AiAnswerEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#aianswerentry)`>` - AiAnswerEntry object containing the answer.
- **getConfig**(): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`KnowledgeRetrievalConfigEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#knowledgeretrievalconfigentry)`>`<br/>
Get the knowledge retrieval configuration.
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`KnowledgeRetrievalConfigEntry`](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md#knowledgeretrievalconfigentry)`>` - KnowledgeRetrievalConfigEntry object containing the configuration.
- **checkSearchAvailability**(selectedNodesState: `SelectionState`, maxSelectedNodes: `number`): `string`<br/>
Check if using of search is possible (if all conditions are met).
- _selectedNodesState:_ `SelectionState` - information about selected nodes.
- _maxSelectedNodes:_ `number` - max number of selected nodes. Default 100.
- **Returns** `string` - string with error if any condition is not met, empty string otherwise.
## Details
See the
[Search Ai API](../../../lib/js-api/src/api/content-rest-api/docs/SearchAiApi.md) for more information about the types returned by [Search Ai
service](search-ai.service.md) methods and for the implementation of the REST API the service is
based on.
-1
View File
@@ -63,4 +63,3 @@ The pages linked below contain the licenses for all third party dependencies of
- [ADF 8.3.1](license-info-8.3.1.md)
- [ADF 8.4.1](license-info-8.4.1.md)
- [ADF 8.5.0](license-info-8.5.0.md)
- [ADF 9.0.0](license-info-9.0.0.md)
-81
View File
@@ -1,81 +0,0 @@
---
Title: License info, alfresco-ng2-components 9.0.0
---
# License information for alfresco-ng2-components 9.0.0
This page lists all third party libraries the project depends on.
## Libraries
| Name | Version | License |
| --- | --- | --- |
| [@angular-eslint/bundled-angular-compiler](https://github.com/angular-eslint/angular-eslint.git) | 20.7.0 | [MIT](https://opensource.org/license/mit/) |
| [@angular-eslint/utils](https://github.com/angular-eslint/angular-eslint.git) | 20.7.0 | [MIT](https://opensource.org/license/mit/) |
| [@angular/animations](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/cdk](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
| [@angular/common](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/compiler](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/core](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/forms](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/material](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
| [@angular/material-date-fns-adapter](https://github.com/angular/components.git) | 20.2.14 | [MIT](https://opensource.org/license/mit/) |
| [@angular/platform-browser](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@angular/router](git+https://github.com/angular/angular.git) | 20.3.25 | [MIT](https://opensource.org/license/mit/) |
| [@apollo/client](git+https://github.com/apollographql/apollo-client.git) | 3.13.1 | [MIT](https://opensource.org/license/mit/) |
| [@babel/runtime](https://github.com/babel/babel.git) | 7.26.10 | [MIT](https://opensource.org/license/mit/) |
| [@cspell/eslint-plugin](https://github.com/streetsidesoftware/cspell.git) | 10.0.0 | [MIT](https://opensource.org/license/mit/) |
| [@graphql-typed-document-node/core](git@github.com:dotansimha/graphql-typed-document-node.git) | 3.2.0 | [MIT](https://opensource.org/license/mit/) |
| [@mat-datetimepicker/core](https://github.com/kuhnroyal/mat-datetimepicker.git) | 16.0.1 | [MIT](https://opensource.org/license/mit/) |
| [@napi-rs/canvas](git+https://github.com/Brooooooklyn/canvas.git) | 0.1.100 | [MIT](https://opensource.org/license/mit/) |
| [@napi-rs/canvas-darwin-arm64](git+https://github.com/Brooooooklyn/canvas.git) | 0.1.100 | [MIT](https://opensource.org/license/mit/) |
| [@ngx-translate/core](https://github.com/ngx-translate/core) | 17.0.0 | [MIT](https://opensource.org/license/mit/) |
| [@wry/caches](git+https://github.com/benjamn/wryware.git) | 1.0.1 | [MIT](https://opensource.org/license/mit/) |
| [@wry/context](git+https://github.com/benjamn/wryware.git) | 0.7.4 | [MIT](https://opensource.org/license/mit/) |
| [@wry/equality](git+https://github.com/benjamn/wryware.git) | 0.5.7 | [MIT](https://opensource.org/license/mit/) |
| [@wry/trie](git+https://github.com/benjamn/wryware.git) | 0.5.0 | [MIT](https://opensource.org/license/mit/) |
| [angular-oauth2-oidc](https://github.com/manfredsteyer/angular-oauth2-oidc) | 19.0.0 | [MIT](https://opensource.org/license/mit/) |
| [apollo-angular](https://github.com/kamilkisiela/apollo-angular) | 11.0.0 | [MIT](https://opensource.org/license/mit/) |
| [balanced-match](git://github.com/juliangruber/balanced-match.git) | 1.0.2 | [MIT](https://opensource.org/license/mit/) |
| [brace-expansion](git://github.com/juliangruber/brace-expansion.git) | 1.1.15 | [MIT](https://opensource.org/license/mit/) |
| [brace-expansion](git://github.com/juliangruber/brace-expansion.git) | 2.1.1 | [MIT](https://opensource.org/license/mit/) |
| [concat-map](git://github.com/substack/node-concat-map.git) | 0.0.1 | [MIT](https://opensource.org/license/mit/) |
| [cropperjs](git+https://github.com/fengyuanchen/cropperjs.git) | 1.6.2 | [MIT](https://opensource.org/license/mit/) |
| [date-fns](https://github.com/date-fns/date-fns) | 2.30.0 | [MIT](https://opensource.org/license/mit/) |
| dotenv-expand | 5.1.0 | [BSD-2-Clause](https://opensource.org/licenses/BSD-2-Clause) |
| editorjs-html | 4.0.5 | [MIT](https://opensource.org/license/mit/) |
| [eve-raphael](https://github.com/tomasAlabes/eve.git) | 0.5.0 | UNKNOWN |
| [eventemitter3](git://github.com/primus/eventemitter3.git) | 5.0.4 | [MIT](https://opensource.org/license/mit/) |
| [fetch-blob](https://github.com/node-fetch/fetch-blob.git) | 3.2.0 | [MIT](https://opensource.org/license/mit/) |
| [formdata-polyfill](git+https://jimmywarting@github.com/jimmywarting/FormData.git) | 4.0.10 | [MIT](https://opensource.org/license/mit/) |
| [graphql-tag](git+https://github.com/apollographql/graphql-tag.git) | 2.12.7 | [MIT](https://opensource.org/license/mit/) |
| [graphql-ws](git+https://github.com/enisdenjo/graphql-ws.git) | 6.0.8 | [MIT](https://opensource.org/license/mit/) |
| [hoist-non-react-statics](git://github.com/mridgway/hoist-non-react-statics.git) | 3.3.2 | [BSD-3-Clause](https://opensource.org/licenses/BSD-3-Clause) |
| [js-tokens](lydell/js-tokens) | 4.0.0 | [MIT](https://opensource.org/license/mit/) |
| [lodash-es](lodash/lodash) | 4.18.1 | [MIT](https://opensource.org/license/mit/) |
| [loose-envify](git://github.com/zertosh/loose-envify.git) | 1.4.0 | [MIT](https://opensource.org/license/mit/) |
| [material-icons](git+https://github.com/marella/material-icons.git) | 1.13.14 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
| [minimatch](git://github.com/isaacs/minimatch.git) | 5.1.9 | [ISC](https://www.isc.org/licenses/) |
| [minimatch-browser](git://github.com/isaacs/minimatch.git) | 1.0.0 | [ISC](https://www.isc.org/licenses/) |
| [ng2-charts](git+https://github.com/valor-software/ng2-charts.git) | 4.1.1 | [ISC](https://www.isc.org/licenses/) |
| [node-domexception](git+https://github.com/jimmywarting/node-domexception.git) | 1.0.0 | [MIT](https://opensource.org/license/mit/) |
| [node-fetch](https://github.com/node-fetch/node-fetch.git) | 3.3.2 | [MIT](https://opensource.org/license/mit/) |
| [object-assign](sindresorhus/object-assign) | 4.1.1 | [MIT](https://opensource.org/license/mit/) |
| [optimism](git+https://github.com/benjamn/optimism.git) | 0.18.1 | [MIT](https://opensource.org/license/mit/) |
| [pdfjs-dist](git+https://github.com/mozilla/pdf.js.git) | 5.1.91 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
| [prop-types](facebook/prop-types) | 15.8.1 | [MIT](https://opensource.org/license/mit/) |
| [raphael](git://github.com/DmitryBaranovskiy/raphael.git) | 2.3.0 | [MIT](https://opensource.org/license/mit/) |
| [react-is](https://github.com/facebook/react.git) | 16.13.1 | [MIT](https://opensource.org/license/mit/) |
| [regenerator-runtime](https://github.com/facebook/regenerator/tree/main/packages/runtime) | 0.14.1 | [MIT](https://opensource.org/license/mit/) |
| [rehackt](git+https://github.com/phryneas/rehackt.git) | 0.1.0 | [MIT](https://opensource.org/license/mit/) |
| [rxjs](https://github.com/reactivex/rxjs.git) | 7.8.2 | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) |
| [symbol-observable](blesh/symbol-observable) | 4.0.0 | [MIT](https://opensource.org/license/mit/) |
| [ts-invariant](git+https://github.com/apollographql/invariant-packages.git) | 0.10.3 | [MIT](https://opensource.org/license/mit/) |
| [tslib](https://github.com/Microsoft/tslib.git) | 2.8.1 | [0BSD](http://landley.net/toybox/license.html) |
| [uuid](https://github.com/uuidjs/uuid.git) | 14.0.1 | [MIT](https://opensource.org/license/mit/) |
| [web-streams-polyfill](git+https://github.com/MattiasBuelens/web-streams-polyfill.git) | 3.3.3 | [MIT](https://opensource.org/license/mit/) |
| [zen-observable](zenparsing/zen-observable) | 0.8.15 | [MIT](https://opensource.org/license/mit/) |
| [zen-observable-ts](git+https://github.com/apollographql/zen-observable-ts.git) | 1.2.5 | [MIT](https://opensource.org/license/mit/) |
| [zone.js](git://github.com/angular/angular.git) | 0.15.0 | [MIT](https://opensource.org/license/mit/) |
-1
View File
@@ -9,7 +9,6 @@ The first **General Availability** release was v2.0.0.
## General Availability
- [9.0.0](RelNote-9.0.0.md)
- [8.5.0](RelNote-8.5.0.md)
- [8.4.1](RelNote-8.4.1.md)
- [8.3.1](RelNote-8.3.1.md)
-48
View File
@@ -1,48 +0,0 @@
---
Title: Changelog for alfresco-ng2-components v9.0.0
---
# Changelog
- [0a19be090e](git@github.com:Alfresco/alfresco-ng2-components/commit/0a19be090e) [ACS-12286] Pass includeFields through to getRecentFiles for -recent- (#12067)
- [b69c3ae1ae](git@github.com:Alfresco/alfresco-ng2-components/commit/b69c3ae1ae) [MNT-25230] Displayed modified time for node version (#12066)
- [ce907417b7](git@github.com:Alfresco/alfresco-ng2-components/commit/ce907417b7) AAE-48166 Compensate for VM/Citrix clock drift in token expiry check (#12024)
- [4c7765e3fa](git@github.com:Alfresco/alfresco-ng2-components/commit/4c7765e3fa) AAE-46517 Add onRowCountChange form event for Repeatable Sections (#12062)
- [407a64996e](git@github.com:Alfresco/alfresco-ng2-components/commit/407a64996e) AAE-46248 Format dropdown/radio option labels in display-text expressions (#12045)
- [01cea60490](git@github.com:Alfresco/alfresco-ng2-components/commit/01cea60490) [MNT-25230] adw version history does not provide user information (#12050)
- [09ef05038c](git@github.com:Alfresco/alfresco-ng2-components/commit/09ef05038c) chore: remove deprecated MaterialModule and its references (#12051)
- [c7bd56f244](git@github.com:Alfresco/alfresco-ng2-components/commit/c7bd56f244) AAE-43974 Fix for alfresco-content-app e2e failure (#12055)
- [b86e41515e](git@github.com:Alfresco/alfresco-ng2-components/commit/b86e41515e) [ACS-12037] Remove Knowledge Discovery from ADF (#12042)
- [0b35118dee](git@github.com:Alfresco/alfresco-ng2-components/commit/0b35118dee) AAE-47880 UI breaks when pasting very large text into text field (#12043)
- [fe2b3d85dd](git@github.com:Alfresco/alfresco-ng2-components/commit/fe2b3d85dd) [ACS-12108] Introduce signed commits into ADF workflows (#12040)
- [b0dc85422c](git@github.com:Alfresco/alfresco-ng2-components/commit/b0dc85422c) Improvement/AAE-43974 Refactoring form fields (#11945)
- [23f0392c12](git@github.com:Alfresco/alfresco-ng2-components/commit/23f0392c12) AAE-47634 Refactor: switch form service getTask to use runtime bundle API (#12020)
- [997303e6a7](git@github.com:Alfresco/alfresco-ng2-components/commit/997303e6a7) [ACS-11325] remove chevron button from tab order in adf-tree (#12041)
- [45d1818f04](git@github.com:Alfresco/alfresco-ng2-components/commit/45d1818f04) [ACS-12098] firefox headless race condition viewer fix (#12023)
- [448e667c52](git@github.com:Alfresco/alfresco-ng2-components/commit/448e667c52) [MNT-25681] Content node selector panel filtering fixed (#12032)
- [18e602aa85](git@github.com:Alfresco/alfresco-ng2-components/commit/18e602aa85) [PRODSEC-13253] Increased peerDependencies versions to allow to increase angular version in other projects (#12031)
- [4cd8648a05](git@github.com:Alfresco/alfresco-ng2-components/commit/4cd8648a05) Adjust release triggers to use cache-writable token (#12030)
- [7f57b3936b](git@github.com:Alfresco/alfresco-ng2-components/commit/7f57b3936b) AAE-39528 Session timeout features (#12008)
- [5a30baad99](git@github.com:Alfresco/alfresco-ng2-components/commit/5a30baad99) Release cache read/write fix (#12029)
- [5050c3d974](git@github.com:Alfresco/alfresco-ng2-components/commit/5050c3d974) [ACS-12041] Add debounceTime in PeopleWidgetComponent to limit API calls on every keystroke (#12027)
- [d8b36606e2](git@github.com:Alfresco/alfresco-ng2-components/commit/d8b36606e2) [MNT-25681] Search refactoring and unification (#12019)
- [37f8fe47ac](git@github.com:Alfresco/alfresco-ng2-components/commit/37f8fe47ac) AAE-46695 Upgrade to pnpm 11.9 (#12028)
- [af0d1e4ead](git@github.com:Alfresco/alfresco-ng2-components/commit/af0d1e4ead) AAE-47093 Update Supply Chain Review GH AW to latest (#12025)
- [bd7f393db9](git@github.com:Alfresco/alfresco-ng2-components/commit/bd7f393db9) AAE-47634 Fix block task claim checking status on Runtime Bundle (#12009)
- [d2d6696c05](git@github.com:Alfresco/alfresco-ng2-components/commit/d2d6696c05) Migrate to Angular 20, TypeScript 5.9, and Angular Material 20 (#11657)
- [7546aa91a3](git@github.com:Alfresco/alfresco-ng2-components/commit/7546aa91a3) AAE-47577 Form Spinner persists outside of Automate Form (#12005)
- [74d67c39a0](git@github.com:Alfresco/alfresco-ng2-components/commit/74d67c39a0) [ACS-11317] a11y Fix: Tags interactive controls are not manageable via keyboard (#11845)
- [20c20f2749](git@github.com:Alfresco/alfresco-ng2-components/commit/20c20f2749) AAE-46247 Add type-aware form field value adapter service (#11968)
- [b498d5744c](git@github.com:Alfresco/alfresco-ng2-components/commit/b498d5744c) [AAE-47470] - Upgrade Pnpm to 11.8.0 (#12004)
- [80eef35e1d](git@github.com:Alfresco/alfresco-ng2-components/commit/80eef35e1d) [ACS-11325] Add keyboard navigation and a11y support for adf-tree component (#11973)
- [49a3f1e104](git@github.com:Alfresco/alfresco-ng2-components/commit/49a3f1e104) AAE-47412 Update suprocesses and linked processes for process instance model (#11996)
- [0beaa18452](git@github.com:Alfresco/alfresco-ng2-components/commit/0beaa18452) [ACS-11973] Fix: context menu disappears during bulk upload (#11994)
- [76fd461741](git@github.com:Alfresco/alfresco-ng2-components/commit/76fd461741) AAE-47042 Add auto grow support to multiline text widget (#11981)
- [78697ee62a](git@github.com:Alfresco/alfresco-ng2-components/commit/78697ee62a) [AAE-0000] - Fixed js-api error index.js (#11970)
- [43c5bc17fb](git@github.com:Alfresco/alfresco-ng2-components/commit/43c5bc17fb) [ACS-12020] Updated init-aps passwords with secret ones (#11993)
- [cda20e0386](git@github.com:Alfresco/alfresco-ng2-components/commit/cda20e0386) Post-release version bump (#11988)
- [8f5099e822](git@github.com:Alfresco/alfresco-ng2-components/commit/8f5099e822) [ACS-11928] convert ReadStream to Blob before appending to FormData (#11987)
- [5dd709ed58](git@github.com:Alfresco/alfresco-ng2-components/commit/5dd709ed58) AAE-44809 Fix form multi-file attachment - document viewer not updated on file selection (#11980)
- [9d353bf271](git@github.com:Alfresco/alfresco-ng2-components/commit/9d353bf271) [ACS-11974] [ACA] Upload new version button is no longer disabled when clicked once (#11977)
- [633702860f](git@github.com:Alfresco/alfresco-ng2-components/commit/633702860f) Post-release version bump (#11984)
- [bf5b3af285](git@github.com:Alfresco/alfresco-ng2-components/commit/bf5b3af285) AAE-47094 Add Stale PR Cleanup workflow (#11983)
+2 -2
View File
@@ -61,8 +61,8 @@ backend services have been tested with each released version of ADF.
<!--7.0.0-alpha.3 start-->
- AgentService — removed, see [Notes](README.md#knowledge-discovery-removal-v900)
- SearchAiService — removed, see [Notes](README.md#knowledge-discovery-removal-v900)
- [AgentService](content-services/services/agent.service.md)
- [SearchAiService](content-services/services/search-ai.service.md)
<!--7.0.0-alpha.3 end-->
-1
View File
@@ -60,4 +60,3 @@ The pages linked below contain the audit for all third party dependencies of ADF
- [ADF 8.3.1](audit-info-8.3.1.md)
- [ADF 8.4.1](audit-info-8.4.1.md)
- [ADF 8.5.0](audit-info-8.5.0.md)
- [ADF 9.0.0](audit-info-9.0.0.md)
-23
View File
@@ -1,23 +0,0 @@
---
Title: Audit info, alfresco-ng2-components 9.0.0
---
# Audit information for alfresco-ng2-components 9.0.0
This page lists the security audit of the dependencies this project depends on.
## Risks
- Critical risk: 0
- High risk: 0
- Moderate risk: 0
- Low risk: 0
Dependencies analyzed: 297
## Libraries
| Severity | Module | Vulnerable versions |
| --- | --- | --- |
+2 -2
View File
@@ -98,8 +98,8 @@ Options:
const packageDir = path.dirname(packagePath);
// Use spawnSync with array arguments for safer command execution (prevents shell injection)
// Cross-platform: pnpm is available on PATH on all platforms (Windows, macOS, Linux)
const result = spawnSync('pnpm', ['audit', '--json', '--prod'], {
// Cross-platform: npm is available on PATH on all platforms (Windows, macOS, Linux)
const result = spawnSync('npm', ['audit', '--json', '--prod'], {
cwd: packageDir,
encoding: 'utf-8',
// shell: false is the default and more secure (no shell interpretation)
+12 -12
View File
@@ -11,19 +11,19 @@
"url": "https://github.com/Alfresco/alfresco-ng2-components/issues"
},
"peerDependencies": {
"@angular/animations": ">=20.3.25",
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/compiler": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/material": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/platform-browser-dynamic": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@alfresco/js-api": ">=10.0.0",
"@angular/animations": ">=14.1.3",
"@angular/cdk": ">=14.1.2",
"@angular/common": ">=14.1.3",
"@angular/compiler": ">=14.1.3",
"@angular/core": ">=14.1.3",
"@angular/forms": ">=14.1.3",
"@angular/material": ">=14.1.2",
"@angular/platform-browser": ">=14.1.3",
"@angular/platform-browser-dynamic": ">=14.1.3",
"@angular/router": ">=14.1.3",
"@alfresco/js-api": ">=9.5.0",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/adf-core": ">=9.0.0"
"@alfresco/adf-core": ">=8.5.0"
},
"keywords": [
"content-services",
@@ -1,7 +0,0 @@
@mixin adf-error-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: text-bottom;
}
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public-api';
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './services/agent.service';
@@ -0,0 +1,67 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { AgentService } from './agent.service';
import { Agent, AgentPaging } from '@alfresco/js-api';
const agent1: Agent = {
id: '1',
name: 'HR Agent',
description: 'Your Claims Doc Agent streamlines the extraction, analysis, and management of data from insurance claims documents.',
avatarUrl: ''
};
const agent2: Agent = {
id: '2',
name: 'Policy Agent',
description: 'Your Claims Doc Agent streamlines the extraction, analysis, and management of data from insurance claims documents.',
avatarUrl: ''
};
const agentPagingObjectMock: AgentPaging = {
list: {
entries: [
{
entry: agent1
},
{
entry: agent2
}
]
}
};
const agentListMock: Agent[] = [agent1, agent2];
describe('AgentService', () => {
let agentService: AgentService;
beforeEach(() => {
agentService = TestBed.inject(AgentService);
});
it('should load agents', (done) => {
spyOn(agentService.agentsApi, 'getAgents').and.returnValue(Promise.resolve(agentPagingObjectMock));
agentService.getAgents().subscribe((pagingResponse) => {
expect(pagingResponse).toEqual(agentListMock);
expect(agentService.agentsApi.getAgents).toHaveBeenCalled();
done();
});
});
});
@@ -0,0 +1,58 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, inject } from '@angular/core';
import { Agent, AgentsApi, LazyApi } from '@alfresco/js-api';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { AlfrescoApiService } from '../../services';
@Injectable({
providedIn: 'root'
})
export class AgentService {
private readonly apiService = inject(AlfrescoApiService);
private readonly agents = new BehaviorSubject<Agent[]>([]);
@LazyApi((self: AgentService) => new AgentsApi(self.apiService.getInstance()))
declare readonly agentsApi: AgentsApi;
agents$ = this.agents.asObservable();
/**
* Gets all agents from cache. If cache is empty, fetches agents from backend.
*
* @returns Agent[] list containing agents.
*/
getAgents(): Observable<Agent[]> {
return this.agents$.pipe(
switchMap((agentsList) => {
if (agentsList.length) {
return of(agentsList);
}
return from(this.agentsApi.getAgents()).pipe(
map((paging) => {
const agentEntries = paging.list.entries.map((agentEntry) => agentEntry.entry);
this.agents.next(agentEntries);
return agentEntries;
})
);
})
);
}
}
@@ -206,9 +206,5 @@ describe('ContentNodeDialogService', () => {
expect(testContentNodeSelectorComponentData.isSelectionValid(testData.node)).toBe(testData.expected);
});
});
it('should scope the search to folders by disabling files in the result', () => {
expect(testContentNodeSelectorComponentData.showFilesInResult).toBe(false);
});
});
});
@@ -154,8 +154,7 @@ export class ContentNodeDialogService {
where: '(isFolder=true)',
isSelectionValid: this.isCopyMoveSelectionValid.bind(this),
excludeSiteContent: excludeSiteContent || ContentNodeDialogService.nonDocumentSiteContent,
select,
showFilesInResult: false
select
};
const dialogRef = this.openContentNodeDialog(data, 'adf-content-node-selector-dialog', '630px');
@@ -197,8 +196,7 @@ export class ContentNodeDialogService {
imageResolver: this.imageResolver.bind(this),
isSelectionValid: this.hasAllowableOperationsOnNodeFolder.bind(this),
where: '(isFolder=true)',
select,
showFilesInResult: false
select
};
const dialogRef = this.openContentNodeDialog(data, 'adf-content-node-selector-dialog', '630px');
@@ -19,7 +19,7 @@ import { DebugElement } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Node, NodeEntry, NodePaging, RequestScope, ResultSetPaging, SiteEntry, SitePaging, SitePagingList } from '@alfresco/js-api';
import { of, Subject } from 'rxjs';
import { of } from 'rxjs';
import { ContentNodeSelectorPanelComponent } from './content-node-selector-panel.component';
import { DocumentListService } from '../../document-list/services/document-list.service';
import { DocumentListComponent } from '../../document-list/components/document-list.component';
@@ -294,32 +294,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
}));
it('should remove the previous site filter query when changing the selected site', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
spyOn(searchQueryBuilderService, 'removeFilterQuery').and.callThrough();
typeToSearchBox('search-term');
tick(debounceSearch);
component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry);
component.siteChanged({ entry: { guid: 'vegeta' } } as SiteEntry);
expect(searchQueryBuilderService.removeFilterQuery).toHaveBeenCalledWith(`ANCESTOR:'workspace://SpacesStore/namek'`);
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(`ANCESTOR:'workspace://SpacesStore/vegeta'`);
expect(searchQueryBuilderService.filterQueries).toEqual([{ query: `ANCESTOR:'workspace://SpacesStore/vegeta'` }]);
}));
it('should browse the selected site folder without searching when no search term is present', fakeAsync(() => {
expect(searchSpy.calls.count()).toBe(0);
component.siteChanged({ entry: { guid: 'namek' } } as SiteEntry);
tick(debounceSearch);
expect(searchSpy).not.toHaveBeenCalled();
expect(component.searchTerm).toBe('');
expect(component.folderIdToShow).toBe('namek');
expect(component.showingSearchResults).toBe(false);
}));
it('should create the query with the right parameters on changing the site selectBox value from a custom dropdown menu', fakeAsync(() => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging;
@@ -369,29 +343,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
expect(getCorrespondingNodeIdsSpy.calls.mostRecent().args[0]).toEqual('-sites-');
}));
it('should execute the search only after the corresponding node ids filter query is applied for aliases', fakeAsync(() => {
component.documentList.folderNode = { id: 'fakeNodeId', isFolder: true, path: {} } as Node;
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
const nodeIds$ = new Subject<string[]>();
getCorrespondingNodeIdsSpy.and.returnValue(nodeIds$.asObservable());
typeToSearchBox('vegeta');
tick(debounceSearch);
searchSpy.calls.reset();
component.siteChanged({ entry: { guid: '-sites-' } } as SiteEntry);
expect(searchSpy).not.toHaveBeenCalled();
nodeIds$.next(['123456testId']);
nodeIds$.complete();
const expectedRequest = `ANCESTOR:'workspace://SpacesStore/-sites-' OR ANCESTOR:'workspace://SpacesStore/123456testId'`;
expect(searchQueryBuilderService.addFilterQuery).toHaveBeenCalledWith(expectedRequest);
expect(searchSpy).toHaveBeenCalledWith(false);
}));
it('should NOT get the corresponding node ids on search when NOTHING is selected from dropdown', fakeAsync(() => {
component.dropdownSiteList = { list: { entries: [{ entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }] } } as SitePaging;
fixture.detectChanges();
@@ -557,7 +508,6 @@ describe('ContentNodeSelectorPanelComponent', () => {
it('should the query restrict the search to the site and not to the currentFolderId in case is changed', async () => {
spyOn(searchQueryBuilderService, 'addFilterQuery').and.callThrough();
component.searchTerm = 'search-term';
searchQueryBuilderService.userQuery = 'search-term*';
component.currentFolderId = 'my-root-id';
component.restrictRootToCurrentFolderId = true;
@@ -40,8 +40,7 @@ import { ImageResolver } from '../../document-list/data/image-resolver.model';
import { CustomResourcesService } from '../../document-list/services/custom-resources.service';
import { ShareDataRow } from '../../document-list/data/share-data-row.model';
import { NodeEntryEvent } from '../../document-list/components/node.event';
import { debounceTime, map } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { ContentNodeSelectorPanelService } from './content-node-selector-panel.service';
import { MatFormFieldModule } from '@angular/material/form-field';
import { TranslatePipe } from '@ngx-translate/core';
@@ -111,7 +110,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
private showSearchField = true;
private showCounter = false;
private _emptyList = true;
private lastParentFilterQuery: string | null = null;
/** If true will restrict the search and breadcrumbs to the currentFolderId */
@Input()
@@ -442,12 +440,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.siteId = chosenSite.entry.guid;
this.setTitleIfCustomSite(chosenSite);
this.siteChange.emit(chosenSite.entry.title);
if (this.searchTerm) {
this.executeSearch(this.searchTerm);
} else {
this.resetFolderToShow();
this.clearSearch();
}
this.executeSearch(this.searchTerm);
}
/**
@@ -474,6 +467,7 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.folderIdToShow = null;
this.preselectedNodes = [];
this.loadingSearchResults = true;
this.addCorrespondingNodeIdsQuery();
this.resetChosenNode();
}
@@ -505,35 +499,25 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.showingSearch.emit(this.showingSearchResults);
}
private addCorrespondingNodeIdsQuery(): Observable<void> {
private addCorrespondingNodeIdsQuery() {
let extraParentFiltering = '';
if (this.customResourcesService.hasCorrespondingNodeIds(this.siteId)) {
return this.customResourcesService.getCorrespondingNodeIds(this.siteId).pipe(
map((nodeIds) => {
let extraParentFiltering = '';
if (nodeIds?.length) {
nodeIds
.filter((id) => id !== this.siteId)
.forEach((extraId) => {
extraParentFiltering += ` OR ANCESTOR:'workspace://SpacesStore/${extraId}'`;
});
}
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'${extraParentFiltering}` : '';
this.setParentFilterQuery(parentFiltering);
})
);
this.customResourcesService.getCorrespondingNodeIds(this.siteId).subscribe((nodeIds) => {
if (nodeIds?.length) {
nodeIds
.filter((id) => id !== this.siteId)
.forEach((extraId) => {
extraParentFiltering += ` OR ANCESTOR:'workspace://SpacesStore/${extraId}'`;
});
}
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'${extraParentFiltering}` : '';
this.queryBuilderService.addFilterQuery(parentFiltering);
});
} else {
const parentFiltering = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'` : '';
this.queryBuilderService.addFilterQuery(parentFiltering);
}
const siteFilter = this.siteId ? `ANCESTOR:'workspace://SpacesStore/${this.siteId}'` : '';
this.setParentFilterQuery(siteFilter);
return of(undefined);
}
private setParentFilterQuery(parentFiltering: string) {
if (this.lastParentFilterQuery) {
this.queryBuilderService.removeFilterQuery(this.lastParentFilterQuery);
}
this.lastParentFilterQuery = parentFiltering || null;
this.queryBuilderService.addFilterQuery(parentFiltering);
}
private setSearchScopeToNodes() {
@@ -701,8 +685,6 @@ export class ContentNodeSelectorPanelComponent implements OnInit {
this.queryBuilderService.searchMode = 'formula';
const wildcardSuffix = this.queryBuilderService.wildcardsEnabled ? '*' : '';
this.queryBuilderService.userQuery = searchValue.length > 0 ? `(${searchValue}${wildcardSuffix})` : searchValue;
this.addCorrespondingNodeIdsQuery()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.queryBuilderService.execute(false));
this.queryBuilderService.execute(false);
}
}
@@ -34,12 +34,14 @@ import { VersionCompatibilityService } from './version-compatibility/version-com
import { contentAuthLoaderFactory } from './auth-loader/content-auth-loader-factory';
import { ContentAuthLoaderService } from './auth-loader/content-auth-loader.service';
import { CONTENT_UPLOAD_DIRECTIVES } from './upload';
import { MaterialModule } from './material.module';
import { AlfrescoApiService } from './services/alfresco-api.service';
import { AlfrescoApiNoAuthService } from './api-factories/alfresco-api-no-auth.service';
import { AlfrescoApiLoaderService, createAlfrescoApiInstance } from './api-factories/alfresco-api-v2-loader.service';
@NgModule({
imports: [
MaterialModule,
MatDatetimepickerModule,
MatNativeDatetimeModule,
...CONTENT_TAG_DIRECTIVES,
@@ -57,6 +59,7 @@ import { AlfrescoApiLoaderService, createAlfrescoApiInstance } from './api-facto
],
providers: [provideTranslations('adf-content-services', 'assets/adf-content-services')],
exports: [
MaterialModule,
...CONTENT_TAG_DIRECTIVES,
...DOCUMENT_LIST_DIRECTIVES,
...CONTENT_UPLOAD_DIRECTIVES,
@@ -16,10 +16,20 @@
/>
@if (form.controls['name'].dirty && form.controls['name'].invalid) {
<mat-error>
<span class="adf-error-text"
>@if (form.controls['name'].errors?.required) {{{ 'CORE.FOLDER_DIALOG.FOLDER_NAME.ERRORS.REQUIRED' | translate }}} @else if (form.controls['name'].errors?.message) {{{ form.controls['name'].errors?.message | translate }}}</span>
@if (form.controls['name'].hasError('required')) {
<span>
{{ 'CORE.FOLDER_DIALOG.FOLDER_NAME.ERRORS.REQUIRED' | translate }}
</span>
}
@else if (form.controls['name'].hasError('message')) {
<span>
{{ form.controls['name'].errors?.message | translate }}
</span>
}
</mat-error>
}
</mat-form-field>
<mat-form-field class="adf-full-width adf-folder-dialog-form-field">
@@ -24,9 +24,6 @@ import {
FavoritePagingList,
NodeEntry,
NodePaging,
Person,
PersonEntry,
ResultSetPaging,
Site,
SiteEntry,
SiteMember,
@@ -197,57 +194,7 @@ describe('CustomResourcesService', () => {
spyOn(customResourcesService, 'getRecentFiles').and.stub();
customResourcesService.loadFolderByNodeId('-recent-', pagination, ['include'], 'where', ['filters']);
expect(customResourcesService.getRecentFiles).toHaveBeenCalledWith('-me-', pagination, ['filters'], ['include']);
});
});
describe('getRecentFiles', () => {
const pagination: PaginationModel = { maxItems: 100, skipCount: 0 };
beforeEach(() => {
spyOn(customResourcesService.peopleApi, 'getPerson').and.returnValue(
Promise.resolve(new PersonEntry({ entry: new Person({ id: 'user' }) }))
);
});
it('should pass includeFields through to the search request, keeping the defaults', (done) => {
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
customResourcesService.getRecentFiles('-me-', pagination, undefined, ['isFavorite']).subscribe(() => {
expect(searchSpy).toHaveBeenCalledTimes(1);
expect(searchSpy.calls.mostRecent().args[0].include).toEqual([
'path',
'properties',
'allowableOperations',
'aspectNames',
'isFavorite'
]);
done();
});
});
it('should keep the default include fields when no includeFields are provided', (done) => {
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
customResourcesService.getRecentFiles('-me-', pagination).subscribe(() => {
expect(searchSpy.calls.mostRecent().args[0].include).toEqual(['path', 'properties', 'allowableOperations', 'aspectNames']);
done();
});
});
it('should not duplicate an includeField that is already a default', (done) => {
const searchSpy = spyOn(customResourcesService.searchApi, 'search').and.returnValue(Promise.resolve(new ResultSetPaging()));
customResourcesService.getRecentFiles('-me-', pagination, undefined, ['properties', 'isFavorite']).subscribe(() => {
expect(searchSpy.calls.mostRecent().args[0].include).toEqual([
'path',
'properties',
'allowableOperations',
'aspectNames',
'isFavorite'
]);
done();
});
expect(customResourcesService.getRecentFiles).toHaveBeenCalledWith('-me-', pagination, ['filters']);
});
});
@@ -73,10 +73,9 @@ export class CustomResourcesService {
* @param personId ID of the user
* @param pagination Specifies how to paginate the results
* @param filters Specifies additional filters to apply (joined with **AND**)
* @param includeFields List of data field names to include in the results
* @returns List of nodes for the recently used files
*/
getRecentFiles(personId: string, pagination: PaginationModel, filters?: string[], includeFields: string[] = []): Observable<ResultSetPaging> {
getRecentFiles(personId: string, pagination: PaginationModel, filters?: string[]): Observable<ResultSetPaging> {
const defaultFilter = [
'TYPE:"content"',
'-PATH:"//cm:wiki/*"',
@@ -122,7 +121,7 @@ export class CustomResourcesService {
language: SEARCH_LANGUAGE.AFTS
},
filterQueries,
include: [...new Set(['path', 'properties', 'allowableOperations', 'aspectNames', ...includeFields])],
include: ['path', 'properties', 'allowableOperations', 'aspectNames'],
sort: [
{
type: 'FIELD',
@@ -381,7 +380,7 @@ export class CustomResourcesService {
} else if (nodeId === '-favorites-') {
return this.loadFavorites(pagination, includeFields, where);
} else if (nodeId === '-recent-') {
return this.getRecentFiles('-me-', pagination, filters, includeFields);
return this.getRecentFiles('-me-', pagination, filters);
} else {
return of(null);
}
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Aktionsmenü öffnen"
},
"ARIA": {
"SELECTED": "{{ name }} ausgewählt",
"DESELECTED": "{{name}} Auswahl aufgehoben"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Anfrage zum Bibliotheksbeitritt abgeschickt"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Bitte wählen Sie nicht mehr als {{ maxFiles }} Dateien.",
"FOLDER_SELECTED": "Ordner sind nicht kompatibel mit KI Agents."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "{{ name }} zu Favoriten hinzugefügt",
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Request sent to join this library"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Please select no more than {{ maxFiles }} files.",
"FOLDER_SELECTED": "Folders are not compatible with AI Agents."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Added {{ name }} to favorites",
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Request sent to join this library"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Please select no more than {{ maxFiles }} files.",
"FOLDER_SELECTED": "Folders are not compatible with AI Agents."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Added {{ name }} to favorites",
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Abrir menú de acciones"
},
"ARIA": {
"SELECTED": "{{name}} seleccionado",
"DESELECTED": "{{name}} deseleccionado"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Se ha enviado una solicitud para unirse a esta biblioteca"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "No seleccione más de {{ maxFiles }} ficheros.",
"FOLDER_SELECTED": "Las carpetas no son compatibles con los Agentes IA."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Añadido {{ name }} a favoritos",
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Ouvrir le menu actions"
},
"ARIA": {
"SELECTED": "{{name}} sélectionné",
"DESELECTED": "{{name}} désélectionné"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Demande envoyée pour rejoindre cette bibliothèque"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Veuillez ne pas sélectionner plus de {{ maxFiles }} fichiers.",
"FOLDER_SELECTED": "Les dossiers ne sont pas compatibles avec les agents IA."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Ajout de {{ name }} aux favoris",
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Apri menu azioni"
},
"ARIA": {
"SELECTED": "{{name}} selezionato",
"DESELECTED": "{{name}} deselezionato"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Richiesta di partecipazione alla libreria inviata"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Selezionare più di {{ maxFiles }} file.",
"FOLDER_SELECTED": "Le cartelle non sono compatibili con gli agenti IA."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Aggiunto {{ name }} ai preferiti",
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Otwórz menu akcji"
},
"ARIA": {
"SELECTED": "Wybrano {{name}}",
"DESELECTED": "Odznaczono {{name}}"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Żądanie dołączenia do tej biblioteki zostało wysłane"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Proszę wybrać nie więcej niż {{ maxFiles }} plików.",
"FOLDER_SELECTED": "Foldery nie są kompatybilne z agentami AI."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Dodano {{ name }} do ulubionych",
+10 -2
View File
@@ -629,8 +629,8 @@
"TOOLTIP": "Abrir menu de ações"
},
"ARIA": {
"SELECTED": "{{name}} selecionado",
"DESELECTED": "{{name}} desmarcado"
"SELECTED": "{{ name }} selected",
"DESELECTED": "{{ name }} deselected"
}
},
"LIBRARY": {
@@ -729,6 +729,14 @@
"JOIN_REQUESTED": "Solicitação enviada para aderir a esta biblioteca"
}
},
"KNOWLEDGE_RETRIEVAL": {
"SEARCH": {
"WARNINGS": {
"TOO_MANY_FILES_SELECTED": "Selecione no máximo {{ maxFiles }} ficheiros.",
"FOLDER_SELECTED": "As pastas não são compatíveis com os Agentes de IA."
}
}
},
"NODE_FAVORITE_DIRECTIVE": {
"MESSAGES": {
"NODE_ADDED": "Adicionado {{ name }} aos favoritos",
@@ -0,0 +1,92 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { MatRippleModule, MatOptionModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSliderModule } from '@angular/material/slider';
import { MatTreeModule } from '@angular/material/tree';
import { MatBadgeModule } from '@angular/material/badge';
/** @deprecated this module is deprecated and will be removed in future versions */
@NgModule({
imports: [
MatButtonModule,
MatAutocompleteModule,
MatChipsModule,
MatDialogModule,
MatIconModule,
MatCardModule,
MatInputModule,
MatListModule,
MatProgressSpinnerModule,
MatProgressBarModule,
MatRippleModule,
MatMenuModule,
MatOptionModule,
MatExpansionModule,
MatSelectModule,
MatCheckboxModule,
MatDatepickerModule,
MatSlideToggleModule,
MatRadioModule,
MatSliderModule,
MatTreeModule,
MatBadgeModule
],
exports: [
MatButtonModule,
MatAutocompleteModule,
MatChipsModule,
MatDialogModule,
MatIconModule,
MatCardModule,
MatInputModule,
MatListModule,
MatProgressSpinnerModule,
MatProgressBarModule,
MatRippleModule,
MatMenuModule,
MatOptionModule,
MatExpansionModule,
MatSelectModule,
MatCheckboxModule,
MatDatepickerModule,
MatSlideToggleModule,
MatRadioModule,
MatSliderModule,
MatTreeModule,
MatBadgeModule
]
})
export class MaterialModule {}
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public-api';
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './services';
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './prediction.service';
@@ -0,0 +1,53 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PredictionService } from './prediction.service';
import { TestBed } from '@angular/core/testing';
import { Prediction, PredictionEntry, PredictionPaging, PredictionPagingList, ReviewStatus } from '@alfresco/js-api';
describe('PredictionService', () => {
let service: PredictionService;
const mockPredictionPaging = (): PredictionPaging => {
const prediction = new Prediction();
prediction.id = 'test id';
const predictionEntry = new PredictionEntry({ entry: prediction });
const predictionPagingList = new PredictionPagingList({ entries: [predictionEntry] });
return new PredictionPaging({ list: predictionPagingList });
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: []
});
service = TestBed.inject(PredictionService);
});
it('should call getPredictions on PredictionsApi with nodeId', () => {
spyOn(service.predictionsApi, 'getPredictions').and.returnValue(Promise.resolve(mockPredictionPaging()));
service.getPredictions('test id');
expect(service.predictionsApi.getPredictions).toHaveBeenCalledWith('test id');
});
it('should call reviewPrediction on PredictionsApi with predictionId and reviewStatus', () => {
spyOn(service.predictionsApi, 'reviewPrediction').and.returnValue(Promise.resolve());
service.reviewPrediction('test id', ReviewStatus.CONFIRMED);
expect(service.predictionsApi.reviewPrediction).toHaveBeenCalledWith('test id', ReviewStatus.CONFIRMED);
});
});
@@ -0,0 +1,50 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, inject } from '@angular/core';
import { PredictionsApi, PredictionPaging, ReviewStatus, LazyApi } from '@alfresco/js-api';
import { from, Observable } from 'rxjs';
import { AlfrescoApiService } from '../../services/alfresco-api.service';
@Injectable({ providedIn: 'root' })
export class PredictionService {
private readonly apiService = inject(AlfrescoApiService);
@LazyApi((self: PredictionService) => new PredictionsApi(self.apiService.getInstance()))
declare readonly predictionsApi: PredictionsApi;
/**
* Get predictions for a given node
*
* @param nodeId The identifier of node.
* @returns Observable<PredictionPaging>
*/
getPredictions(nodeId: string): Observable<PredictionPaging> {
return from(this.predictionsApi.getPredictions(nodeId));
}
/**
* Review a prediction
*
* @param predictionId The identifier of prediction.
* @param reviewStatus Review status to apply.
* @returns Observable<void>
*/
reviewPrediction(predictionId: string, reviewStatus: ReviewStatus): Observable<void> {
return from(this.predictionsApi.reviewPrediction(predictionId, reviewStatus));
}
}
@@ -0,0 +1,18 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './public-api';
@@ -0,0 +1,22 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface SearchAiInputState {
active: boolean;
selectedAgentId?: string;
searchTerm?: string;
}
@@ -0,0 +1,19 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './services/search-ai.service';
export * from './models/search-ai-input-state';
@@ -0,0 +1,246 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { AiAnswerEntry, KnowledgeRetrievalConfigEntry, Node, QuestionModel, QuestionRequest } from '@alfresco/js-api';
import { SearchAiService } from './search-ai.service';
import { SearchAiInputState } from '../models/search-ai-input-state';
import { TranslateService } from '@ngx-translate/core';
describe('SearchAiService', () => {
let service: SearchAiService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: []
});
service = TestBed.inject(SearchAiService);
});
describe('ask', () => {
it('should load information about question', (done) => {
const question: QuestionModel = {
question: 'some question',
questionId: 'some id',
restrictionQuery: { nodesIds: ['nodeId1', 'nodeId2'] }
};
spyOn(service.searchAiApi, 'ask').and.returnValue(Promise.resolve(question));
const questionRequest: QuestionRequest = {
question: 'some question',
nodeIds: ['nodeId1', 'nodeId2'],
agentId: 'some id'
};
service.ask(questionRequest).subscribe((questionResponse) => {
expect(questionResponse).toBe(question);
expect(service.searchAiApi.ask).toHaveBeenCalledWith([questionRequest]);
done();
});
});
});
describe('getAnswer', () => {
it('should load information about question', (done) => {
const questionId = 'some id';
const answer: AiAnswerEntry = {
entry: {
answer: 'Some answer 1',
complete: true,
question: 'Some question',
objectReferences: [
{
objectId: 'some id 1',
references: [
{
referenceId: 'some reference id 1',
rank: 1,
rankScore: 0.005
},
{
referenceId: 'some reference id 2',
rank: 2,
rankScore: 0.004
}
]
},
{
objectId: 'some id 2',
references: [
{
referenceId: 'some reference id 3',
rank: 1,
rankScore: 0.005
},
{
referenceId: 'some reference id 4',
rank: 2,
rankScore: 0.004
}
]
}
]
}
};
spyOn(service.searchAiApi, 'getAnswer').and.returnValue(Promise.resolve(answer));
service.getAnswer(questionId).subscribe((answerResponse) => {
expect(answerResponse).toBe(answer);
expect(service.searchAiApi.getAnswer).toHaveBeenCalledWith(questionId);
done();
});
});
});
describe('getConfig', () => {
it('should load knowledge retrieval configuration', (done) => {
const config: KnowledgeRetrievalConfigEntry = {
entry: {
knowledgeRetrievalUrl: 'https://some-url'
}
};
spyOn(service.searchAiApi, 'getConfig').and.returnValue(Promise.resolve(config));
service.getConfig().subscribe((configResponse) => {
expect(configResponse).toBe(config);
expect(service.searchAiApi.getConfig).toHaveBeenCalled();
done();
});
});
});
describe('updateSearchAiInputState', () => {
it('should trigger toggleSearchAiInput$', () => {
const state: SearchAiInputState = {
active: true,
selectedAgentId: 'some id'
};
service.updateSearchAiInputState(state);
service.toggleSearchAiInput$.subscribe((receivedState) => {
expect(receivedState).toBe(state);
});
});
});
describe('checkSearchAvailability', () => {
let translateService: TranslateService;
const tooManyFilesSelectedError = 'Please select no more than 100 files.';
const folderSelectedError = 'Folders are not compatible with AI Agents.';
beforeEach(() => {
translateService = TestBed.inject(TranslateService);
spyOn(translateService, 'instant').and.callFake((key) => {
switch (key) {
case 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED':
return tooManyFilesSelectedError;
case 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.FOLDER_SELECTED':
return folderSelectedError;
default:
return '';
}
});
});
it('should not return error if user did not select any files', () => {
expect(
service.checkSearchAvailability({
count: 0,
nodes: [],
libraries: [],
isEmpty: true
})
).toEqual('');
});
it('should return error for too many files selected', () => {
expect(
service.checkSearchAvailability({
count: 101,
nodes: [],
libraries: [],
isEmpty: false
})
).toBe(tooManyFilesSelectedError);
expect(translateService.instant).toHaveBeenCalledWith('KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED', {
maxFiles: 100,
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED'
});
});
it('should return error for folder selected', () => {
expect(
service.checkSearchAvailability({
count: 1,
nodes: [
{
entry: {
isFolder: true
} as Node
}
],
libraries: [],
isEmpty: false
})
).toBe(folderSelectedError);
});
it('should return error for folder and if non text mime type node is selected', () => {
expect(
service.checkSearchAvailability({
count: 1,
nodes: [
{
entry: {
isFolder: true,
content: {
mimeType: 'some mime type',
mimeTypeName: 'some mime type',
sizeInBytes: 100
}
} as Node
}
],
libraries: [],
isEmpty: false
})
).toBe(folderSelectedError);
});
it('should return more than one error if more validators detected issues', () => {
expect(
service.checkSearchAvailability({
count: 101,
nodes: [
{
entry: {
isFolder: true,
content: {
mimeType: 'image/jpeg',
mimeTypeName: 'image/jpeg',
sizeInBytes: 100
}
} as Node
}
],
libraries: [],
isEmpty: false
})
).toBe(`${tooManyFilesSelectedError} ${folderSelectedError}`);
});
});
});
@@ -0,0 +1,105 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, inject } from '@angular/core';
import { AiAnswerEntry, KnowledgeRetrievalConfigEntry, LazyApi, QuestionModel, QuestionRequest, SearchAiApi } from '@alfresco/js-api';
import { BehaviorSubject, from, Observable } from 'rxjs';
import { SelectionState } from '@alfresco/adf-extensions';
import { TranslateService } from '@ngx-translate/core';
import { SearchAiInputState } from '../models/search-ai-input-state';
import { AlfrescoApiService } from '../../services';
@Injectable({
providedIn: 'root'
})
export class SearchAiService {
private readonly apiService = inject(AlfrescoApiService);
private readonly translateService = inject(TranslateService);
private readonly toggleSearchAiInput = new BehaviorSubject<SearchAiInputState>({
active: false
});
@LazyApi((self: SearchAiService) => new SearchAiApi(self.apiService.getInstance()))
declare readonly searchAiApi: SearchAiApi;
toggleSearchAiInput$ = this.toggleSearchAiInput.asObservable();
/**
* Update the state of the search AI input.
*
* @param state The new state of the search AI input.
*/
updateSearchAiInputState(state: SearchAiInputState): void {
this.toggleSearchAiInput.next(state);
}
/**
* Ask a question to the AI.
*
* @param question The question to ask.
* @returns QuestionModel object containing information about questions.
*/
ask(question: QuestionRequest): Observable<QuestionModel> {
return from(this.searchAiApi.ask([question]));
}
/**
* Get an answer to specific question.
*
* @param questionId The ID of the question to get an answer for.
* @returns AiAnswerEntry object containing the answer.
*/
getAnswer(questionId: string): Observable<AiAnswerEntry> {
return from(this.searchAiApi.getAnswer(questionId));
}
/**
* Get the knowledge retrieval configuration.
*
* @returns KnowledgeRetrievalConfigEntry object containing the configuration.
*/
getConfig(): Observable<KnowledgeRetrievalConfigEntry> {
return from(this.searchAiApi.getConfig());
}
/**
* Check if using of search is possible (if all conditions are met).
*
* @param selectedNodesState information about selected nodes.
* @param maxSelectedNodes max number of selected nodes. Default 100.
* @returns string with error if any condition is not met, empty string otherwise.
*/
checkSearchAvailability(selectedNodesState: SelectionState, maxSelectedNodes = 100): string {
const messages: {
key: string;
[parameter: string]: number | string;
}[] = [];
if (selectedNodesState.count > maxSelectedNodes) {
messages.push({
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.TOO_MANY_FILES_SELECTED',
maxFiles: maxSelectedNodes
});
}
if (selectedNodesState.nodes.some((node) => node.entry.isFolder)) {
messages.push({
key: 'KNOWLEDGE_RETRIEVAL.SEARCH.WARNINGS.FOLDER_SELECTED'
});
}
return messages.map((message) => this.translateService.instant(message.key, message)).join(' ');
}
}
@@ -24,14 +24,8 @@
type="text"
(keypress)="cleanErrorMsg()"
[(ngModel)]="newTagName"
[errorStateMatcher]="tagErrorStateMatcher"
/>
@if (errorMsg) {
<mat-error data-automation-id="errorMessage">
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ errorMsg }}</span>
</mat-error>
}
<mat-hint data-automation-id="errorMessage" *ngIf="error" [ngStyle]="{ color: 'red' }" align="start">{{ errorMsg }} </mat-hint>
</mat-form-field>
</td>
<td>
@@ -1,5 +1,3 @@
@use '../../_mixins' as mixins;
.adf-tag-node-actions-list {
.adf-full-width {
width: 100%;
@@ -20,7 +18,3 @@
height: 20px;
}
}
.adf-full-width .adf-error-icon {
@include mixins.adf-error-icon;
}
@@ -16,8 +16,6 @@
*/
import { IconModule, TranslationService } from '@alfresco/adf-core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatIconModule } from '@angular/material/icon';
import { Component, DestroyRef, EventEmitter, inject, Input, OnChanges, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { TagService } from '../services/tag.service';
import { TagPaging } from '@alfresco/js-api';
@@ -37,17 +35,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'adf-tag-node-actions-list',
imports: [
CommonModule,
MatListModule,
IconModule,
MatIconModule,
MatFormFieldModule,
MatInputModule,
TranslatePipe,
FormsModule,
MatButtonModule
],
imports: [CommonModule, MatListModule, IconModule, MatFormFieldModule, MatInputModule, TranslatePipe, FormsModule, MatButtonModule],
templateUrl: './tag-actions.component.html',
styleUrls: ['./tag-actions.component.scss'],
encapsulation: ViewEncapsulation.None,
@@ -78,10 +66,6 @@ export class TagActionsComponent implements OnChanges, OnInit {
errorMsg: string;
disableAddTag: boolean = true;
tagErrorStateMatcher: ErrorStateMatcher = {
isErrorState: () => !!this.errorMsg
};
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
@@ -58,9 +58,7 @@
*ngIf="node.hasChildren"
class="adf-tree-expand-collapse-button"
mat-icon-button
tabindex="-1"
aria-hidden="true"
(click)="expandCollapseNode(node); $event.stopPropagation()"
matTreeNodeToggle
>
<mat-progress-spinner
mode="indeterminate"
@@ -213,26 +213,6 @@ describe('TreeComponent', () => {
expect(expandSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
it('should call expandCollapseNode once when chevron is clicked', () => {
component.refreshTree();
fixture.detectChanges();
component.treeService.treeNodes = Array.from(treeNodesMock);
component.treeService.treeNodes[0].isLoading = false;
fixture.detectChanges();
const expandCollapseNodeSpy = spyOn(component, 'expandCollapseNode').and.callThrough();
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
expect(expandCollapseNodeSpy).toHaveBeenCalledOnceWith(component.treeService.treeNodes[0]);
});
it('should not trigger node selection when chevron is clicked and selectableNodes is true', () => {
component.selectableNodes = true;
component.refreshTree();
fixture.detectChanges();
const onNodeSelectedSpy = spyOn(component, 'onNodeSelected');
clickExpandCollapseBtn(component.treeService.treeNodes[0].id);
expect(onNodeSelectedSpy).not.toHaveBeenCalled();
});
it('should call collapseNode on TreeService when collapsing node by clicking at node label and node has children', () => {
component.refreshTree();
fixture.detectChanges();
@@ -12,15 +12,8 @@
<span class="adf-version-list-item-line adf-version-list-item-version"
[id]="'adf-version-list-item-version-' + version.entry.id">{{ version.entry.id }}</span> -
<span class="adf-version-list-item-line adf-version-list-item-date"
[id]="'adf-version-list-item-date-' + version.entry.id">{{ version.entry.modifiedAt | date: 'medium' }}</span>
[id]="'adf-version-list-item-date-' + version.entry.id">{{ version.entry.modifiedAt | date }}</span>
</p>
@if (version.entry.modifiedByUser?.displayName) {
<p
class="adf-version-list-item-line"
[attr.data-automation-id]="`adf-version-list-item-modified-by-${version.entry.id}`">
{{ version.entry.modifiedByUser.displayName }}
</p>
}
<p
[id]="'adf-version-list-item-comment-' + version.entry.id"
class="adf-version-list-item-comment"
@@ -16,49 +16,29 @@
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { VersionListComponent, VersionListDataSource } from './version-list.component';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { Node, NodeEntry, VersionEntry, Version, UserInfo } from '@alfresco/js-api';
import { Node, NodeEntry, VersionEntry, Version } from '@alfresco/js-api';
import { ContentVersionService } from './content-version.service';
import { take } from 'rxjs/operators';
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
import { NoopAuthModule, UnitTestingUtils } from '@alfresco/adf-core';
import { NoopAuthModule } from '@alfresco/adf-core';
import { provideApiTesting } from '../testing/providers';
import { DebugElement } from '@angular/core';
describe('VersionListComponent', () => {
let component: VersionListComponent;
let fixture: ComponentFixture<VersionListComponent>;
let dialog: MatDialog;
let contentVersionService: ContentVersionService;
let testingUtils: UnitTestingUtils;
const nodeId = 'test-id';
const versionId = '1.0';
const versionTest = [
new VersionEntry({
entry: new Version({
name: 'test-file-name',
id: '1.0',
versionComment: 'test-version-comment',
modifiedByUser: new UserInfo({
displayName: 'TestUser1'
}),
modifiedAt: new Date(2026, 0, 15, 9, 56, 42, 581)
})
}),
new VersionEntry({
entry: new Version({
name: 'test-file-name-two',
id: '1.1',
versionComment: 'test-version-comment',
modifiedByUser: new UserInfo({
displayName: 'TestUser2'
})
})
})
new VersionEntry({ entry: new Version({ name: 'test-file-name', id: '1.0', versionComment: 'test-version-comment' }) }),
new VersionEntry({ entry: new Version({ name: 'test-file-name-two', id: '1.0', versionComment: 'test-version-comment' }) })
];
afterEach(() => {
@@ -74,7 +54,6 @@ describe('VersionListComponent', () => {
fixture = TestBed.createComponent(VersionListComponent);
dialog = TestBed.inject(MatDialog);
contentVersionService = TestBed.inject(ContentVersionService);
testingUtils = new UnitTestingUtils(fixture.debugElement);
component = fixture.componentInstance;
component.node = { id: nodeId, allowableOperations: ['update'] } as Node;
@@ -135,18 +114,16 @@ describe('VersionListComponent', () => {
});
describe('Version history fetching', () => {
const getLoadingProgressBar = (): DebugElement => testingUtils.getByDataAutomationId('version-history-loading-bar');
const getCommentElement = (): DebugElement => testingUtils.getByCSS('.adf-version-list-item-comment');
it('should use loading bar', (done) => {
fixture.detectChanges();
expect(getLoadingProgressBar()).toBeNull();
let loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]'));
expect(loadingProgressBar).toBeNull();
component.versionsDataSource.isLoading.pipe(take(1)).subscribe(() => {
fixture.detectChanges();
expect(getLoadingProgressBar()).not.toBeNull();
loadingProgressBar = fixture.debugElement.query(By.css('[data-automation-id="version-history-loading-bar"]'));
expect(loadingProgressBar).not.toBeNull();
done();
});
@@ -170,18 +147,13 @@ describe('VersionListComponent', () => {
fixture.whenStable().then(() => {
fixture.detectChanges();
const versionFileName = testingUtils.getInnerTextByCSS('.adf-version-list-item-name');
const versionIdText = testingUtils.getInnerTextByCSS('.adf-version-list-item-version');
const versionComment = getCommentElement().nativeElement.innerText;
const versionFileName = fixture.debugElement.query(By.css('.adf-version-list-item-name')).nativeElement.innerText;
const versionIdText = fixture.debugElement.query(By.css('.adf-version-list-item-version')).nativeElement.innerText;
const versionComment = fixture.debugElement.query(By.css('.adf-version-list-item-comment')).nativeElement.innerText;
expect(versionFileName).toBe('test-file-name');
expect(versionIdText).toBe('1.0');
expect(versionComment.trim()).toBe('test-version-comment');
expect(testingUtils.getInnerTextByDataAutomationId('adf-version-list-item-modified-by-1.0')).toBe('TestUser1');
const expectedModifiedAt = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'medium' }).format(
versionTest[0].entry.modifiedAt
);
expect(testingUtils.getInnerTextByCSS('#adf-version-list-item-date-1\\.0')).toBe(expectedModifiedAt);
done();
});
});
@@ -194,8 +166,9 @@ describe('VersionListComponent', () => {
fixture.whenStable().then(() => {
fixture.detectChanges();
const versionCommentEl = fixture.debugElement.query(By.css('.adf-version-list-item-comment'));
expect(getCommentElement()).toBeNull();
expect(versionCommentEl).toBeNull();
done();
});
});
@@ -295,15 +268,15 @@ describe('VersionListComponent', () => {
const getActionMenuButton = (version = '1.0'): HTMLButtonElement => {
fixture.detectChanges();
return testingUtils.getByCSS(`[id="adf-version-list-action-menu-button-${version}"]`)?.nativeElement;
return fixture.debugElement.query(By.css(`[id="adf-version-list-action-menu-button-${version}"]`))?.nativeElement;
};
const getRestoreButton = (version = '1.0'): HTMLButtonElement => {
getActionMenuButton(version).click();
return testingUtils.getByCSS(`[id="adf-version-list-action-restore-${version}"]`)?.nativeElement;
return fixture.debugElement.query(By.css(`[id="adf-version-list-action-restore-${version}"]`))?.nativeElement;
};
const getDeleteButton = (version = '1.1'): DebugElement => testingUtils.getByCSS(`[id="adf-version-list-action-delete-${version}"]`);
const getDeleteButton = (version = '1.1') => fixture.debugElement.query(By.css(`[id="adf-version-list-action-delete-${version}"]`));
beforeEach(() => {
fixture.detectChanges();
@@ -423,7 +396,7 @@ describe('VersionListComponent', () => {
beforeEach(() => {
fixture.detectChanges();
virtualListViewport = testingUtils.getByDirective(CdkFixedSizeVirtualScroll).injector.get(CdkFixedSizeVirtualScroll);
virtualListViewport = fixture.debugElement.query(By.directive(CdkFixedSizeVirtualScroll)).injector.get(CdkFixedSizeVirtualScroll);
});
it('should have assigned correct minBufferPx', () => {
@@ -32,8 +32,7 @@ import {
ViewerMoreActionsComponent,
ViewerToolbarActionsComponent,
NoopAuthModule,
NoopTranslateModule,
UnitTestingUtils
NoopTranslateModule
} from '@alfresco/adf-core';
import { NodesApiService } from '../../common/services/nodes-api.service';
import { UploadService } from '../../common/services/upload.service';
@@ -178,7 +177,6 @@ describe('AlfrescoViewerComponent', () => {
let renditionService: RenditionService;
let viewUtilService: ViewUtilService;
let nodeActionsService: NodeActionsService;
let testingUtils: UnitTestingUtils;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -199,7 +197,6 @@ describe('AlfrescoViewerComponent', () => {
fixture = TestBed.createComponent(AlfrescoViewerComponent);
element = fixture.nativeElement;
component = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
uploadService = TestBed.inject(UploadService);
nodesApiService = TestBed.inject(NodesApiService);
dialog = TestBed.inject(MatDialog);
@@ -401,16 +398,12 @@ describe('AlfrescoViewerComponent', () => {
component.nodeId = 'id1';
component.showViewer = true;
component.versionId = null;
component.ngOnChanges(getSimpleChanges('id1'));
await fixture.ngZone.run(async () => {
component.ngOnChanges(getSimpleChanges('id1'));
await fixture.whenStable();
});
fixture.detectChanges();
await fixture.whenStable();
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
expect(viewer.fileName).toBe('file1.pdf');
expect(viewer.blobFile).toBe(mockBlob);
expect(component.fileName).toBe('file1.pdf');
expect(component.blobFileContent).toBe(mockBlob);
});
it('should change display name every time node`s version changes', fakeAsync(() => {
@@ -1070,127 +1063,4 @@ describe('AlfrescoViewerComponent', () => {
});
});
});
describe('PDF blob fetch and atomic state assignment', () => {
it('should assign blobFileContent and null urlFileContent when PDF blob fetch succeeds', async () => {
const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' });
const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response;
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
spyOn(component.nodesApi, 'getNode').and.returnValue(
Promise.resolve(
new NodeEntry({
entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) })
})
)
);
component.nodeId = 'node-1';
component.showViewer = true;
await fixture.ngZone.run(async () => {
component.ngOnChanges(getSimpleChanges('node-1'));
await fixture.whenStable();
});
fixture.detectChanges();
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
expect(viewer.blobFile).toBe(mockBlob);
expect(viewer.urlFile).toBeFalsy();
expect(viewer.mimeType).toBe('application/pdf');
});
it('should fall back to URL-based viewing when PDF blob fetch fails', async () => {
spyOn(window, 'fetch').and.returnValue(Promise.reject(new Error('Network error')));
spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url');
spyOn(component.nodesApi, 'getNode').and.returnValue(
Promise.resolve(
new NodeEntry({
entry: new Node({
name: 'test.pdf',
id: 'node-1',
content: new ContentInfo({ mimeType: 'application/pdf' }),
properties: { 'cm:versionLabel': '1.0' }
})
})
)
);
component.nodeId = 'node-1';
component.showViewer = true;
await fixture.ngZone.run(async () => {
component.ngOnChanges(getSimpleChanges('node-1'));
await fixture.whenStable();
});
fixture.detectChanges();
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
expect(viewer.blobFile).toBeFalsy();
expect(viewer.urlFile).toBeTruthy();
expect(viewer.mimeType).toBe('application/pdf');
});
it('should fall back to URL-based viewing when PDF fetch returns non-ok response', async () => {
const mockResponse = { ok: false, status: 403 } as Response;
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
spyOn(component.contentApi, 'getContentUrl').and.returnValue('/content/url');
spyOn(component.nodesApi, 'getNode').and.returnValue(
Promise.resolve(
new NodeEntry({
entry: new Node({
name: 'test.pdf',
id: 'node-1',
content: new ContentInfo({ mimeType: 'application/pdf' }),
properties: { 'cm:versionLabel': '1.0' }
})
})
)
);
component.nodeId = 'node-1';
component.showViewer = true;
await fixture.ngZone.run(async () => {
component.ngOnChanges(getSimpleChanges('node-1'));
await fixture.whenStable();
});
fixture.detectChanges();
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
expect(viewer.blobFile).toBeFalsy();
expect(viewer.urlFile).toBeTruthy();
expect(viewer.mimeType).toBe('application/pdf');
});
it('should not expose intermediate null state during node setup', async () => {
const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' });
const mockResponse = { ok: true, blob: () => Promise.resolve(mockBlob) } as Response;
spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse));
spyOn(component.nodesApi, 'getNode').and.returnValue(
Promise.resolve(
new NodeEntry({
entry: new Node({ name: 'test.pdf', id: 'node-1', content: new ContentInfo({ mimeType: 'application/pdf' }) })
})
)
);
component.nodeId = 'node-1';
component.showViewer = true;
await fixture.ngZone.run(async () => {
component.ngOnChanges(getSimpleChanges('node-1'));
await fixture.whenStable();
});
fixture.detectChanges();
const viewer = testingUtils.getByDirective(ViewerComponent).componentInstance as ViewerComponent<unknown>;
expect(viewer.blobFile).toBe(mockBlob);
expect(viewer.urlFile).toBeFalsy();
expect(viewer.fileName).toBe('test.pdf');
});
});
});
@@ -275,9 +275,9 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
private async onNodeUpdated(node: Node) {
if (node && node.id === this.nodeId) {
this.generateCacheBusterNumber();
this.blobFileContent = null;
await this.setUpNodeFile(node);
this.cdr.detectChanges();
}
}
@@ -300,6 +300,7 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
private async setupNode() {
try {
this.blobFileContent = null;
this.nodeEntry = await this.nodesApi.getNode(this.nodeId, { include: ['allowableOperations'] });
if (this.versionId) {
this.versionEntry = await this.versionsApi.getVersion(this.nodeId, this.versionId);
@@ -317,7 +318,6 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
this.canEditNode = this.contentService.hasAllowableOperations(nodeData, 'update');
let mimeType: string;
let urlFileContent: string;
let blobContent: Blob = null;
if (versionData?.content) {
mimeType = versionData.content.mimeType;
@@ -352,24 +352,32 @@ export class AlfrescoViewerComponent implements OnChanges, OnInit {
} else if (viewerType === 'media') {
this.tracks = await this.renditionService.generateMediaTracksRendition(this.nodeId);
} else if (viewerType === 'pdf') {
const contentUrl = versionData
? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id)
: this.contentApi.getContentUrl(this.nodeId);
try {
const contentUrl = versionData
? this.contentApi.getVersionContentUrl(this.nodeId, versionData.id)
: this.contentApi.getContentUrl(this.nodeId);
const blob = await fetch(contentUrl, { credentials: 'include', headers: { 'Cache-Control': 'no-cache' } })
.then((response) => (response.ok ? response.blob() : null))
.catch(() => null);
// Fetch the content as Blob using fetch API with credentials
const response = await fetch(contentUrl, {
credentials: 'include',
headers: {
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (blob) {
blobContent = blob;
this.blobFileContent = await response.blob();
urlFileContent = null;
} catch (error) {
console.error('[ADF DEBUG] Failed to fetch PDF as blob, falling back to URL', error);
}
}
this.mimeType = mimeType;
this.nodeMimeType = nodeMimeType;
this.fileName = versionData ? versionData.name : nodeData.name;
this.blobFileContent = blobContent;
this.urlFileContent = urlFileContent ? urlFileContent + (this.cacheBusterNumber ? '&' + this.cacheBusterNumber : '') : null;
this.sidebarRightTemplateContext.node = nodeData;
this.sidebarLeftTemplateContext.node = nodeData;
+4
View File
@@ -44,8 +44,12 @@ export * from './lib/security/index';
export * from './lib/api-factories';
export * from './lib/services/index';
export * from './lib/infinite-scroll-datasource';
export * from './lib/prediction/index';
export * from './lib/legal-hold/index';
export * from './lib/api-factories';
export * from './lib/mock/alfresco-api.service.mock';
export * from './lib/agent/index';
export * from './lib/search-ai/index';
export * from './lib/content.module';
export * from './lib/material.module';
+11 -11
View File
@@ -22,19 +22,19 @@
"date-fns": "^2.30.0"
},
"peerDependencies": {
"@angular/animations": ">=20.3.25",
"@angular/cdk": ">=20.2.14",
"@angular/common": ">=20.3.25",
"@angular/core": ">=20.3.25",
"@angular/forms": ">=20.3.25",
"@angular/material": ">=20.2.14",
"@angular/material-date-fns-adapter": ">=20.2.14",
"@angular/platform-browser": ">=20.3.25",
"@angular/router": ">=20.3.25",
"@angular/animations": ">=16.0.0",
"@angular/cdk": ">=16.0.0",
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@angular/material": ">=16.0.0",
"@angular/material-date-fns-adapter": ">=16.0.0",
"@angular/platform-browser": ">=16.0.0",
"@angular/router": ">=16.0.0",
"@mat-datetimepicker/core": ">=12.0.1",
"@ngx-translate/core": ">=17.0.0",
"@alfresco/js-api": ">=10.0.0",
"@alfresco/adf-extensions": ">=9.0.0",
"@alfresco/js-api": ">=9.5.0",
"@alfresco/adf-extensions": ">=8.5.0",
"minimatch": ">=10.0.0",
"pdfjs-dist": ">=3.3.122",
"rxjs": ">=7.8.0"
@@ -204,48 +204,4 @@ describe('AppConfigService', () => {
expect(appConfigService.get<any>('objectKey').secondUrl).toEqual('http://localhost:8080');
expect(appConfigService.get<any>('objectKey').thirdUrl).toEqual('http://localhost:8080');
});
describe('oauth2', () => {
it('should set showDebugInformation to true when configured as boolean true', () => {
appConfigService.config = { oauth2: { showDebugInformation: true } };
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
});
it('should set showDebugInformation to true when configured as the string "true"', () => {
appConfigService.config = { oauth2: { showDebugInformation: 'true' } };
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
});
it('should set showDebugInformation to false when configured as false', () => {
appConfigService.config = { oauth2: { showDebugInformation: false } };
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
});
it('should default showDebugInformation to false when not configured', () => {
appConfigService.config = { oauth2: {} };
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
});
it('should set timeSync to true when configured as boolean true', () => {
appConfigService.config = { oauth2: { timeSync: true } };
expect(appConfigService.oauth2.timeSync).toBeTrue();
});
it('should set timeSync to true when configured as the string "true"', () => {
appConfigService.config = { oauth2: { timeSync: 'true' } };
expect(appConfigService.oauth2.timeSync).toBeTrue();
});
it('should default timeSync to false when not configured', () => {
appConfigService.config = { oauth2: {} };
expect(appConfigService.oauth2.timeSync).toBeFalse();
});
});
});
@@ -44,8 +44,7 @@ export const AppConfigValues = {
LOGIN_ROUTE: 'loginRoute',
DISABLECSRF: 'disableCSRF',
AUTH_WITH_CREDENTIALS: 'auth.withCredentials',
AUTH_TIME_SYNC_ENABLED: 'oauth2.timeSync',
AUTH_SHOW_DEBUG_INFORMATION: 'oauth2.showDebugInformation',
AUTH_TIME_SYNC_ENABLED: 'auth.timeSync.enabled',
SERVER_TIME_URL: 'serverTimeUrl',
APPLICATION: 'application',
STORAGE_PREFIX: 'application.storagePrefix',
@@ -260,15 +259,13 @@ export class AppConfigService {
const silentLogin = config['silentLogin'] === true || config['silentLogin'] === 'true';
const codeFlow = config['codeFlow'] === true || config['codeFlow'] === 'true';
const timeSync = config['timeSync'] === true || config['timeSync'] === 'true';
const showDebugInformation = config['showDebugInformation'] === true || config['showDebugInformation'] === 'true';
return {
...(config as OauthConfigModel),
implicitFlow,
silentLogin,
codeFlow,
timeSync,
showDebugInformation
timeSync
};
}
@@ -33,5 +33,4 @@ export interface OauthConfigModel {
clockSkewInSec?: number;
sessionChecksEnabled?: boolean;
timeSync?: boolean;
showDebugInformation?: boolean;
}
File diff suppressed because it is too large Load Diff
@@ -29,15 +29,27 @@ import {
OAuthLogger
} from 'angular-oauth2-oidc';
import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler';
import { firstValueFrom, from, Observable, race, ReplaySubject } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
import { firstValueFrom, from, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, scan, shareReplay, switchMap, take } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
import { RetryLoginService } from './retry-login.service';
import { TimeSyncService } from '../services/time-sync.service';
import { ClockSyncResult, TimeSync, TimeSyncService } from '../services/time-sync.service';
const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
/** Tracks OAuth errors so token refresh errors can keep their existing one-retry behavior. */
interface OAuthErrorProcessingState {
/** Latest OAuth error event that may need clock-skew handling. */
event: OAuthErrorEvent | null;
/** Whether the current event should be evaluated by the clock-skew pipeline. */
shouldProcess: boolean;
/** Number of consecutive token refresh errors seen since the last successful token event. */
tokenRefreshErrorCount: number;
}
@Injectable()
export class RedirectAuthService extends AuthService {
private readonly oauthService = inject(OAuthService);
@@ -51,9 +63,9 @@ export class RedirectAuthService extends AuthService {
private readonly _isDiscoveryDocumentLoadedSubject$ = new ReplaySubject<boolean>();
public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable();
onLogin: Observable<any>;
onLogin!: Observable<any>;
onTokenReceived: Observable<any>;
onTokenReceived!: Observable<any>;
private _loadDiscoveryDocumentPromise = Promise.resolve(false);
@@ -63,7 +75,7 @@ export class RedirectAuthService extends AuthService {
* This observable listens to the events emitted by the OAuth service and filters
* them to only include instances of OAuthSuccessEvent with the type `logout`.
*/
onLogout$: Observable<void>;
onLogout$!: Observable<void>;
/**
* Observable stream that emits OAuthErrorEvent instances.
@@ -72,40 +84,36 @@ export class RedirectAuthService extends AuthService {
* them to only include instances of OAuthErrorEvent. It then maps these events
* to the correct type.
*/
oauthErrorEvent$: Observable<OAuthErrorEvent>;
oauthErrorEvent$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs.
*/
firstOauthErrorEventOccur$: Observable<OAuthErrorEvent>;
firstOauthErrorEventOccur$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors.
*/
firstOauthErrorEventExcludingTokenRefreshError$: Observable<OAuthErrorEvent>;
firstOauthErrorEventExcludingTokenRefreshError$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the second OAuth token refresh error event that occurs.
*/
secondTokenRefreshErrorEventOccur$: Observable<OAuthErrorEvent>;
/**
* Observable that emits an error when the token has expired due to
* the local machine clock being out of sync with the server time.
*/
tokenHasExpiredDueToClockOutOfSync$: Observable<Error>;
secondTokenRefreshErrorEventOccur$!: Observable<OAuthErrorEvent>;
/**
* Observable that emits an error when the OAuth error event occurs due to
* the local machine clock being out of sync with the server time.
* When clock drift is detected, it re-syncs the clock and requests a new token
* before propagating the error (if the refresh still fails).
*/
oauthErrorEventOccurDueToClockOutOfSync$: Observable<Error>;
oauthErrorEventOccurDueToClockOutOfSync$!: Observable<Error>;
/**
* Observable stream that emits either OAuthErrorEvent or Error.
* This stream combines multiple OAuth error sources into a single observable.
*/
combinedOAuthErrorsStream$: Observable<OAuthErrorEvent | Error>;
combinedOAuthErrorsStream$!: Observable<OAuthErrorEvent | Error>;
/** Subscribe to whether the user has valid Id/Access tokens. */
authenticated$!: Observable<boolean>;
@@ -149,76 +157,313 @@ export class RedirectAuthService extends AuthService {
this.oauthService.clearHashAfterLogin = true;
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation)).subscribe((event) => {
this.subscribeToDebugOAuthEvents(oauthService);
this.initializeOAuthEventStreams();
this.subscribeToCombinedOAuthErrors();
this.removeInvalidStoredAccessTokenAfterClockSync();
}
/**
* Logs OAuth events when the underlying OAuth service has debug output enabled.
* This preserves the library-controlled debug behavior and keeps production logging quiet.
*
* @param oauthService OAuth service instance captured before event subscriptions are created
*/
private subscribeToDebugOAuthEvents(oauthService: OAuthService): void {
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation === true)).subscribe((event) => {
if (event instanceof OAuthErrorEvent) {
this._oauthLogger.error('OAuthErrorEvent Object:', event);
} else {
this._oauthLogger.info('OAuthEvent Object:', event);
}
});
}
this.oauthErrorEvent$ = this.oauthService.events.pipe(
/**
* Creates all public OAuth event streams exposed by this service.
* These streams preserve the existing logout/error behavior and isolate the constructor from
* the mechanics of each observable pipeline.
*/
private initializeOAuthEventStreams(): void {
this.oauthErrorEvent$ = this.createOAuthErrorEventStream();
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.createFirstOAuthErrorExcludingTokenRefreshStream();
this.secondTokenRefreshErrorEventOccur$ = this.createSecondTokenRefreshErrorStream();
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.createClockOutOfSyncErrorStream();
this.authenticated$ = this.createAuthenticatedStream();
this.onLogout$ = this.createLogoutStream();
this.combinedOAuthErrorsStream$ = this.createCombinedOAuthErrorStream();
this.onLogin = this.createLoginStream();
this.onTokenReceived = this.createTokenReceivedStream();
this.idpUnreachable$ = this.createIdpUnreachableStream();
}
/**
* Emits every OAuth event that represents an OAuth error.
*
* @returns OAuth error event stream
*/
private createOAuthErrorEventStream(): Observable<OAuthErrorEvent> {
return this.oauthService.events.pipe(
filter((event) => event instanceof OAuthErrorEvent),
map((event) => event as OAuthErrorEvent)
);
}
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.oauthErrorEvent$.pipe(
/**
* Emits the first OAuth error that is not a token refresh error.
* Token refresh errors have a separate second-failure path so the OAuth library can retry once.
*
* @returns first non-token-refresh OAuth error stream
*/
private createFirstOAuthErrorExcludingTokenRefreshStream(): Observable<OAuthErrorEvent> {
return this.oauthErrorEvent$.pipe(
filter((event) => event instanceof OAuthErrorEvent && event.type !== 'token_refresh_error'),
take(1)
);
}
this.secondTokenRefreshErrorEventOccur$ = this.oauthErrorEvent$.pipe(
/**
* Emits the second token refresh error, preserving the existing retry allowance for the first one.
*
* @returns second token refresh error stream
*/
private createSecondTokenRefreshErrorStream(): Observable<OAuthErrorEvent> {
return this.oauthErrorEvent$.pipe(
filter((event) => event.type === 'token_refresh_error'),
take(2),
filter((_, index) => index === 1)
);
}
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe(
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
filter((timeSync) => timeSync?.outOfSync),
map(
(timeSync) =>
new Error(
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
)
),
/**
* Emits a clock-out-of-sync error when OAuth errors occur and the corrected clock still shows
* the token cannot be trusted after a re-sync attempt.
*
* @returns clock-out-of-sync error stream
*/
private createClockOutOfSyncErrorStream(): Observable<Error> {
return this.createLogoutCausingOAuthErrorStream().pipe(
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
filter((result): result is Error => result instanceof Error),
take(1)
);
}
this.authenticated$ = this.oauthService.events.pipe(
/**
* Emits OAuth errors that would log the user out unless clock recovery suppresses them.
* Non-refresh errors are handled once; token refresh errors keep the existing one-retry
* behavior and are handled only on the second consecutive failure.
*
* @returns logout-causing OAuth error stream
*/
private createLogoutCausingOAuthErrorStream(): Observable<OAuthErrorEvent> {
return this.oauthService.events.pipe(
scan((state, event) => this.updateOAuthErrorProcessingState(state, event), {
event: null,
shouldProcess: false,
tokenRefreshErrorCount: 0
} as OAuthErrorProcessingState),
filter(({ shouldProcess }) => shouldProcess),
map(({ event }) => event as OAuthErrorEvent)
);
}
/**
* Updates the token-refresh error counter used by clock-skew detection.
* The first token refresh error is skipped so the OAuth library retry path can run; successful
* token events reset the counter so isolated refresh failures do not accumulate.
*
* @param state current OAuth error processing state
* @param event latest OAuth event
* @returns updated OAuth error processing state
*/
private updateOAuthErrorProcessingState(state: OAuthErrorProcessingState, event: OAuthEvent): OAuthErrorProcessingState {
if (event instanceof OAuthErrorEvent) {
return {
event,
shouldProcess: event.type !== 'token_refresh_error' || state.tokenRefreshErrorCount >= 1,
tokenRefreshErrorCount: event.type === 'token_refresh_error' ? state.tokenRefreshErrorCount + 1 : state.tokenRefreshErrorCount
};
}
return {
event: null,
shouldProcess: false,
tokenRefreshErrorCount: this.isSuccessfulTokenEvent(event) ? 0 : state.tokenRefreshErrorCount
};
}
/**
* Checks whether an OAuth event represents a successful token update.
*
* @param event OAuth event emitted by the OAuth service
* @returns true when token refresh error counters should be reset
*/
private isSuccessfulTokenEvent(event: OAuthEvent): boolean {
return event.type === 'token_received' || event.type === 'token_refreshed';
}
/**
* Re-syncs the clock before deciding whether an OAuth error should still log the user out.
* Disabled time sync or a failed first sync falls back to the original OAuth error. A fresh
* sync, or a failed sync with a previous trusted offset, allows corrected-clock recovery.
*
* @param event OAuth error currently being handled
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
*/
private resolveOAuthErrorAfterClockSync(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
return this._timeSyncService.syncClockOffsetResult().pipe(
switchMap((syncResult) => {
if (!this.canUseCorrectedClock(syncResult)) {
return of(event);
}
return this.resolveOAuthErrorWithCorrectedClock(event);
}),
catchError(() => of(event))
);
}
/**
* Whether a sync result gives the service a trusted corrected clock for recovery decisions.
* A failed sync can still be trusted when a previous successful sync supplied the active offset.
*
* @param syncResult result from the latest clock sync attempt
* @returns true when corrected-clock recovery can be attempted
*/
private canUseCorrectedClock(syncResult: ClockSyncResult): boolean {
return syncResult.status === 'synced' || syncResult.hasSuccessfulSync;
}
/**
* Uses the corrected clock to decide whether an OAuth error is recoverable.
* If the corrected token time is valid, logout is suppressed. If the token is still expired,
* a refresh is attempted before emitting the clock-out-of-sync error.
*
* @param event OAuth error currently being handled
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
*/
private resolveOAuthErrorWithCorrectedClock(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
return this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec ?? 0).pipe(
switchMap((timeSync) => {
if (!timeSync?.outOfSync) {
return of(event);
}
return this.refreshExpiredTokenWhenClockOutOfSync(timeSync);
}),
catchError(() => of(event))
);
}
/**
* Attempts token recovery when the corrected clock shows the local clock is out of sync.
*
* @param timeSync corrected clock status
* @returns null when token validation or refresh succeeds, otherwise a clock-out-of-sync error
*/
private refreshExpiredTokenWhenClockOutOfSync(timeSync: TimeSync): Observable<Error | null> {
if (!this.tokenHasExpired()) {
return of(null);
}
return from(this.oauthService.refreshToken()).pipe(
map(() => null),
catchError(() => of(this.createClockOutOfSyncError(timeSync)))
);
}
/**
* Creates the error emitted when OAuth handling determines the local clock is out of sync.
*
* @param timeSync clock sync details used in the error message
* @returns clock-out-of-sync error
*/
private createClockOutOfSyncError(timeSync: TimeSync): Error {
return new Error(
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
);
}
/**
* Emits authentication state changes derived from OAuth events.
*
* @returns authenticated state stream
*/
private createAuthenticatedStream(): Observable<boolean> {
return this.oauthService.events.pipe(
map(() => this.authenticated),
distinctUntilChanged(),
shareReplay(1)
);
}
this.tokenHasExpiredDueToClockOutOfSync$ = this.oauthService.events.pipe(
map(() => !!this.oauthService.getIdentityClaims() && this.tokenHasExpired()),
filter((hasExpired) => hasExpired),
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
filter((timeSync) => timeSync?.outOfSync),
map(
(timeSync) =>
new Error(
`Token has expired due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
)
),
take(1)
);
this.onLogout$ = this.oauthService.events.pipe(
/**
* Emits when the OAuth service reports logout.
*
* @returns logout notification stream
*/
private createLogoutStream(): Observable<void> {
return this.oauthService.events.pipe(
filter((event) => event.type === 'logout'),
map(() => undefined)
);
}
this.combinedOAuthErrorsStream$ = race([
this.oauthErrorEventOccurDueToClockOutOfSync$,
this.firstOauthErrorEventExcludingTokenRefreshError$,
this.tokenHasExpiredDueToClockOutOfSync$,
this.secondTokenRefreshErrorEventOccur$
]);
/**
* Combines the OAuth error streams that should cause a single logout.
*
* @returns first logout-causing OAuth error or clock error
*/
private createCombinedOAuthErrorStream(): Observable<OAuthErrorEvent | Error> {
return this.createLogoutCausingOAuthErrorStream().pipe(
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
filter((result): result is OAuthErrorEvent | Error => result !== null),
take(1)
);
}
/**
* Emits when the user becomes authenticated.
*
* @returns login notification stream
*/
private createLoginStream(): Observable<void> {
return this.authenticated$.pipe(
filter((authenticated) => authenticated),
map(() => undefined)
);
}
/**
* Emits when OAuth tokens are received.
*
* @returns token received notification stream
*/
private createTokenReceivedStream(): Observable<void> {
return this.oauthService.events.pipe(
filter((event: OAuthEvent) => event.type === 'token_received'),
map(() => undefined)
);
}
/**
* Emits discovery-document load failures as IdP reachability errors.
*
* @returns IdP unreachable error stream
*/
private createIdpUnreachableStream(): Observable<Error> {
return this.oauthService.events.pipe(
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
map((event) => event.reason as Error)
);
}
/**
* Subscribes to the combined OAuth error stream and logs out once an error wins the race.
*/
private subscribeToCombinedOAuthErrors(): void {
this.combinedOAuthErrorsStream$.subscribe({
next: (res) => {
this._oauthLogger.error(res);
@@ -226,21 +471,16 @@ export class RedirectAuthService extends AuthService {
},
error: () => {}
});
}
const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken();
this.oauthService.events.pipe(take(1)).subscribe(() => {
if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) {
if (this.oauthService.showDebugInformation) {
this._oauthLogger.warn('Access token not valid. Removing all auth items from storage');
}
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
}
});
/**
* Removes stored auth data when an initially invalid access token remains invalid after clock sync.
*/
private removeInvalidStoredAccessTokenAfterClockSync(): void {
this.oauthService.events
.pipe(
filter(() => this._timeSyncService.isEnabled() && hasInvalidAccessToken()),
take(1),
filter(() => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()),
switchMap(() => this._timeSyncService.syncClockOffset())
)
.subscribe(() => {
@@ -248,24 +488,9 @@ export class RedirectAuthService extends AuthService {
if (this.oauthService.showDebugInformation) {
this._oauthLogger.warn('Access token not valid after clock resync. Removing all auth items from storage');
}
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
this.AUTH_STORAGE_ITEMS.forEach((item: string) => this._oauthStorage.removeItem(item));
}
});
this.onLogin = this.authenticated$.pipe(
filter((authenticated) => authenticated),
map(() => undefined)
);
this.onTokenReceived = this.oauthService.events.pipe(
filter((event: OAuthEvent) => event.type === 'token_received'),
map(() => undefined)
);
this.idpUnreachable$ = this.oauthService.events.pipe(
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
map((event) => event.reason as Error)
);
}
init(): Promise<boolean> {
@@ -277,6 +502,7 @@ export class RedirectAuthService extends AuthService {
}
logout() {
this._timeSyncService.stopPeriodicSync();
this.oauthService.logOut();
}
@@ -326,19 +552,41 @@ export class RedirectAuthService extends AuthService {
}
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
const tryToLogin = () =>
this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
});
return this.ensureDiscoveryDocument().then(() =>
(this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(tryToLogin) : tryToLogin()).then(() =>
this._getRedirectUrl()
)
);
return this.ensureDiscoveryDocument()
.then(() => this.syncClockBeforeLoginCallback())
.then(() => this.tryLoginCallback(loginOptions))
.then(() => this._getRedirectUrl());
}
/**
* Waits for the optional clock sync attempt before OAuth validates the login callback tokens.
* When time sync is disabled or cannot sync, `TimeSyncService` completes using the old raw-clock
* behavior, so this only changes behavior when a trusted server time is available.
*
* @returns promise that resolves after the clock sync decision has completed
*/
private syncClockBeforeLoginCallback(): Promise<void> {
return firstValueFrom(this._timeSyncService.syncClockOffset());
}
/**
* Runs the existing retry-login flow with the auth-module login callback options applied.
*
* @param loginOptions options received by `loginCallback`
* @returns promise that resolves when OAuth login succeeds
*/
private tryLoginCallback(loginOptions?: LoginOptions): Promise<boolean> {
return this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
});
}
/**
* Resolves the redirect URL stored before login, then removes the temporary state entry.
*
* @returns stored redirect URL, or `/` when no redirect state exists
*/
private _getRedirectUrl() {
const DEFAULT_REDIRECT = '/';
const stateKey = this.oauthService.state;
@@ -355,26 +603,53 @@ export class RedirectAuthService extends AuthService {
return DEFAULT_REDIRECT;
}
/**
* Applies OAuth configuration, loads discovery metadata, and starts auth background helpers.
* Loading errors are converted to `false` so unprotected routes can still render.
*
* @param config OAuth configuration to apply
* @returns promise resolving to true when configuration completes, otherwise false
*/
private configureAuth(config: AuthConfig): Promise<boolean> {
this.oauthService.configure(config);
this.oauthService.tokenValidationHandler = new WebCryptoJwksValidationHandler();
this.subscribeToSessionTermination(config);
return this.ensureDiscoveryDocument()
.then(() => this.completeAuthConfiguration())
.catch(() => {
// catch error to prevent the app from crashing when trying to access unprotected routes
return false;
});
}
/**
* Subscribes to session termination logout events only when session checks are enabled.
*
* @param config OAuth configuration currently being applied
*/
private subscribeToSessionTermination(config: AuthConfig): void {
if (config.sessionChecksEnabled) {
this.oauthService.events.pipe(filter((event) => event.type === 'session_terminated')).subscribe(() => {
this.oauthService.logOut();
});
}
}
const initializeAuth = () =>
this.ensureDiscoveryDocument().then(() => {
this._isDiscoveryDocumentLoadedSubject$.next(true);
this.oauthService.setupAutomaticSilentRefresh();
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
});
return (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(initializeAuth) : initializeAuth()).catch(() => {
// catch error to prevent the app from crashing when trying to access unprotected routes
});
/**
* Finishes auth setup after discovery metadata has loaded.
* This keeps the existing eager sync/periodic sync behavior and multi-tab refresh patch.
*
* @returns true when auth configuration completes
*/
private completeAuthConfiguration(): boolean {
this._isDiscoveryDocumentLoadedSubject$.next(true);
this.oauthService.setupAutomaticSilentRefresh();
this._timeSyncService.syncClockOffset().subscribe();
this._timeSyncService.startPeriodicSync();
this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
return true;
}
/**
@@ -398,12 +673,13 @@ export class RedirectAuthService extends AuthService {
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_received'));
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_refreshed'));
lastUpdatedAccessToken = this.oauthService.getAccessToken();
return;
return undefined as unknown as TokenResponse;
}
const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken();
return originalRefreshToken().then((resp) => {
lastUpdatedAccessToken = resp.access_token;
return resp;
});
});
const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService);
@@ -417,18 +693,11 @@ export class RedirectAuthService extends AuthService {
lastUpdatedAccessToken = this.oauthService.getAccessToken();
return event;
} else {
if (this._timeSyncService.isEnabled()) {
await this.firstValueFromSyncClockOffset();
}
return originalSilentRefresh(params, noPrompt);
}
});
}
private firstValueFromSyncClockOffset(): Promise<void> {
return firstValueFrom(this._timeSyncService.syncClockOffset());
}
updateIDPConfiguration(config: AuthConfig) {
this.oauthService.configure(config);
}
@@ -451,10 +720,11 @@ export class RedirectAuthService extends AuthService {
const now = this._timeSyncService.getCorrectedNow();
const issuedAtMSec = claims.iat * 1000;
const expiresAtMSec = claims.exp * 1000;
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
const clockSkewInMSec = (this.oauthService.clockSkewInSec ?? 0) * 1000;
const decreaseExpirationBySec = this.oauthService.decreaseExpirationBySec ?? 0;
this.showTokenExpiredDebugInformations(now, issuedAtMSec, expiresAtMSec, clockSkewInMSec);
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now;
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - decreaseExpirationBySec <= now;
}
private showTokenExpiredDebugInformations(now: number, issuedAtMSec: number, expiresAtMSec: number, clockSkewInMSec: number) {
@@ -463,11 +733,10 @@ export class RedirectAuthService extends AuthService {
this._oauthLogger.warn('issuedAt: ', new Date(issuedAtMSec));
this._oauthLogger.warn('expiresAt: ', new Date(expiresAtMSec));
this._oauthLogger.warn('clockSkewInMSec: ', clockSkewInMSec);
this._oauthLogger.warn('this.oauthService.decreaseExpirationBySec: ', this.oauthService.decreaseExpirationBySec);
this._oauthLogger.warn('issuedAtMSec - clockSkewInMSec >= now: ', issuedAtMSec - clockSkewInMSec >= now);
this._oauthLogger.warn(
'expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now: ',
expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now
expiresAtMSec + clockSkewInMSec - (this.oauthService.decreaseExpirationBySec ?? 0) <= now
);
}
}
@@ -17,15 +17,14 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting, TestRequest } from '@angular/common/http/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OAuthLogger } from 'angular-oauth2-oidc';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { AppConfigService } from '../../app-config/app-config.service';
import { TimeSyncService } from './time-sync.service';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { LogService } from '../../common/services/log.service';
import { ClockSyncResult, TimeSyncService } from './time-sync.service';
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
const MAX_ALLOWED_CLOCK_SKEW_IN_SEC = 120;
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
const SERVER_TIME_URL = '/api/server-time';
type ClockDirection = 'behind' | 'ahead';
@@ -38,22 +37,13 @@ interface ClockSkewScenario {
interface AppConfigOptions {
timeSync?: boolean | string;
omitTimeSync?: boolean;
showDebugInformation?: boolean | string;
}
interface TimeSyncResult {
outOfSync: boolean;
timeOffsetInSec?: number;
localDateTimeISO: string;
serverDateTimeISO: string;
serverTimeUrl?: unknown;
}
describe('TimeSyncService', () => {
let service: TimeSyncService;
let httpMock: HttpTestingController;
let appConfigService: AppConfigService;
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
let appConfigGetSpy: jasmine.Spy;
const clockSkewScenarios: ClockSkewScenario[] = [
{ id: 'TC-01', description: 'baseline login with an accurate clock', skewSeconds: 0, direction: 'behind' },
@@ -84,363 +74,575 @@ describe('TimeSyncService', () => {
{ id: 'TC-26', description: 'relogin after logout while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }
];
const configureApp = (options: AppConfigOptions = {}): void => {
appConfigService.config = {
oauth2: options.omitTimeSync ? {} : { timeSync: options.timeSync ?? true, showDebugInformation: options.showDebugInformation ?? false }
};
const configureApp = ({ timeSync = true, serverTimeUrl = SERVER_TIME_URL }: AppConfigOptions = {}): void => {
appConfigGetSpy.and.callFake(<T>(key: string, defaultValue?: T): T => {
if (key === AppConfigValues.OAUTHCONFIG) {
return timeSync === undefined ? ({} as T) : ({ timeSync } as T);
}
if (key === AppConfigValues.SERVER_TIME_URL) {
return serverTimeUrl as T;
}
return defaultValue as T;
});
};
const rawLocalInstantFor = ({ skewSeconds, direction }: Pick<ClockSkewScenario, 'skewSeconds' | 'direction'>): number =>
const rawLocalInstantFor = (skewSeconds: number, direction: ClockDirection): number =>
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
const expectedOffsetInMsFor = (localNow: number, serverNow = SERVER_NOW): number => serverNow - localNow;
const expectedOffsetFor = (localNow: number, serverNow: number = SERVER_NOW): number => serverNow - localNow;
const appRootUrl = (): string => window.location.href.split('?')[0].split('#')[0];
const expectAppRootTimeRequest = (expectCacheBusting = true): TestRequest => {
const request = httpMock.expectOne((req) => req.url === appRootUrl());
const expectServerTimeRequest = (url = SERVER_TIME_URL): TestRequest => {
const request = httpMock.expectOne(url);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
if (expectCacheBusting) {
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
expect(request.request.headers.get('Pragma')).toBe('no-cache');
expect(request.request.params.has('adf-time-sync')).toBeTrue();
} else {
expect(request.request.headers.has('Cache-Control')).toBeFalse();
expect(request.request.headers.has('Pragma')).toBeFalse();
expect(request.request.params.has('adf-time-sync')).toBeFalse();
}
return request;
};
const flushDateHeader = (request: TestRequest, serverNow = SERVER_NOW): void => {
request.flush('', { headers: { date: new Date(serverNow).toUTCString() } });
};
const syncWithServerTime = async (localNow: number, serverNow: number = SERVER_NOW): Promise<ClockSyncResult> => {
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow);
const expectTimeSyncResult = (result: TimeSyncResult, expected: TimeSyncResult): void => {
expect(result).toEqual(expected);
const sync = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${serverNow}`);
return sync;
};
beforeEach(() => {
oauthLoggerSpy = jasmine.createSpyObj<OAuthLogger>('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']);
TestBed.configureTestingModule({
providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, provideHttpClient(), provideHttpClientTesting()]
providers: [TimeSyncService, provideHttpClient(), provideHttpClientTesting()]
});
service = TestBed.inject(TimeSyncService);
httpMock = TestBed.inject(HttpTestingController);
appConfigService = TestBed.inject(AppConfigService);
appConfigGetSpy = spyOn(TestBed.inject(AppConfigService), 'get');
configureApp();
spyOn(performance, 'now').and.returnValue(0);
});
afterEach(() => {
service.stopPeriodicSync();
httpMock.verify();
});
describe('syncClockOffset', () => {
it('should keep raw local time and not request server time when timeSync is not configured', async () => {
configureApp({ omitTimeSync: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
it('should complete as disabled and not request server time when time sync is absent', async () => {
configureApp({ timeSync: undefined });
service.clockOffsetMs = 60_000;
await firstValueFrom(service.syncClockOffset());
const result = await firstValueFrom(service.syncClockOffsetResult());
httpMock.expectNone(() => true);
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 238_000);
expect(result).toEqual({
status: 'disabled',
appliedOffsetMs: 0,
previousOffsetMs: 60_000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(60_000);
});
it('should keep raw local time and not request server time when timeSync is false', async () => {
configureApp({ timeSync: false });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
});
it('should correct a slow local clock when timeSync is true', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should correct a fast local clock when timeSync is the string true', async () => {
it('should accept string true from oauth2.timeSync because AppConfigService normalizes it', async () => {
configureApp({ timeSync: 'true' });
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should read server time from the app root Date header', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
it('should fail without requesting when serverTimeUrl is not configured', async () => {
configureApp({ serverTimeUrl: undefined });
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
const result = await firstValueFrom(service.syncClockOffsetResult());
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
httpMock.expectNone(() => true);
expect(result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
});
it('should correct local time without requiring serverTimeUrl configuration', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
it('should fail without requesting when serverTimeUrl has an unsupported scheme', async () => {
configureApp({ serverTimeUrl: 'ftp://example.com/server-time' });
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
const result = await firstValueFrom(service.syncClockOffsetResult());
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
httpMock.expectNone(() => true);
expect(result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
});
it('should keep raw local time when timeSync is true but the server time request fails', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
it('should request a configured relative serverTimeUrl using GET text', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const sync = firstValueFrom(service.syncClockOffset());
expectAppRootTimeRequest().error(new ProgressEvent('error'));
await sync;
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest('/api/server-time').flush(`${SERVER_NOW - 480_000}`);
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: -480_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should fall back to raw local time when a later sync fails after the cached server time expires', fakeAsync(() => {
let localNow = SERVER_NOW - 238_000;
spyOn(Date, 'now').and.callFake(() => localNow);
it('should request a configured absolute serverTimeUrl using GET text', async () => {
const serverTimeUrl = 'https://time.example.com/server-time';
configureApp({ serverTimeUrl });
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
service.syncClockOffset().subscribe();
flushDateHeader(expectAppRootTimeRequest());
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest(serverTimeUrl).flush(`${SERVER_NOW + 60_000}`);
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
localNow = SERVER_NOW + 60_000;
service.syncClockOffset().subscribe();
expectAppRootTimeRequest().error(new ProgressEvent('error'));
it('should calculate the offset with half the measured round-trip time', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW + 1000);
(performance.now as jasmine.Spy).and.returnValues(0, 1000);
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
}));
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 59_500,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW + 1000
});
expect(service.clockOffsetMs).toBe(59_500);
});
[
{ format: 'date string', responseBody: new Date(SERVER_NOW + 30_000).toUTCString() },
{ format: 'epoch millisecond', responseBody: `${SERVER_NOW + 30_000}` },
{ format: 'epoch second', responseBody: `${(SERVER_NOW + 30_000) / 1000}` }
].forEach(({ format, responseBody }) => {
it(`should parse ${format} response bodies`, async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(responseBody);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 30_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
});
it('should keep the current offset when the request fails', async () => {
service.clockOffsetMs = 5000;
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await result).toEqual({
status: 'failed',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should report failed with a trusted previous sync when a later request fails', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 30_000);
const synced = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
await synced;
const failed = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await failed).toEqual({
status: 'failed',
appliedOffsetMs: 60_000,
previousOffsetMs: 60_000,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should report missing-server-time and keep the current offset for an empty body', async () => {
service.clockOffsetMs = 5000;
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(' ');
expect(await result).toEqual({
status: 'missing-server-time',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should report invalid-server-time and keep the current offset for an invalid body', async () => {
service.clockOffsetMs = 5000;
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush('not-a-date');
expect(await result).toEqual({
status: 'invalid-server-time',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should share an in-flight sync request between overlapping callers', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const firstSync = firstValueFrom(service.syncClockOffsetResult());
const secondSync = firstValueFrom(service.syncClockOffsetResult());
const requests = httpMock.match(SERVER_TIME_URL);
expect(requests.length).toBe(1);
requests[0].flush(`${SERVER_NOW + 60_000}`);
const expectedResult = {
status: 'synced' as const,
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
};
expect(await firstSync).toEqual(expectedResult);
expect(await secondSync).toEqual(expectedResult);
});
it('should not treat an un-subscribed sync observable as in flight', async () => {
service.syncClockOffsetResult();
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
expect((await result).status).toBe('synced');
});
it('should reject an implausible offset and keep the previous value', async () => {
const events: { measuredOffsetMs: number; maxAllowedOffsetMs: number }[] = [];
service.clockOffsetMs = 1234;
service.implausibleOffsetDetected$.subscribe((event) => events.push(event));
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 660_000}`);
expect(await result).toEqual({
status: 'implausible-offset',
appliedOffsetMs: 1234,
previousOffsetMs: 1234,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(1234);
expect(events).toEqual([{ measuredOffsetMs: 660_000, maxAllowedOffsetMs: 600_000 }]);
});
});
describe('checkTimeSync', () => {
it('should error when the server time request fails', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
describe('clock reads and status checks', () => {
it('should return corrected now when enabled and an offset is stored', () => {
service.clockOffsetMs = -60_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
expectAppRootTimeRequest().error(new ProgressEvent('error'));
await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time');
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should use the app root Date header as the server time source', async () => {
it('should return raw now when disabled even if an offset is stored', () => {
configureApp({ timeSync: false });
service.clockOffsetMs = -60_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
});
it('should report out-of-sync from the stored offset when enabled', async () => {
service.clockOffsetMs = 180_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
const result = await firstValueFrom(service.checkTimeSync(120));
expectTimeSyncResult(await check, {
expect(result).toEqual({
outOfSync: true,
timeOutOfSyncInSec: 180,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW + 180_000).toISOString()
});
});
it('should report in-sync from raw time when disabled even if an offset is stored', async () => {
configureApp({ timeSync: false });
service.clockOffsetMs = 180_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const result = await firstValueFrom(service.checkTimeSync(120));
expect(result).toEqual({
outOfSync: false,
timeOffsetInSec: 0,
timeOutOfSyncInSec: 0,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
});
});
it('should map checkTimeSync to a boolean in isLocalTimeOutOfSync', async () => {
service.clockOffsetMs = 121_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
expect(await firstValueFrom(service.isLocalTimeOutOfSync(120))).toBeTrue();
});
});
describe('server time request sharing', () => {
it('should perform a single HTTP request when multiple callers subscribe concurrently', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const emitted: number[] = [];
describe('periodic sync', () => {
beforeEach(() => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
});
service.syncClockOffset().subscribe(() => emitted.push(service.getCorrectedNow()));
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe((result) => emitted.push(new Date(result.serverDateTimeISO).getTime()));
it('should not register periodic sync when disabled', () => {
configureApp({ timeSync: false });
const addEventListenerSpy = spyOn(document, 'addEventListener');
flushDateHeader(expectAppRootTimeRequest());
service.startPeriodicSync(1000);
expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]);
expect(addEventListenerSpy).not.toHaveBeenCalled();
httpMock.expectNone(() => true);
});
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
it('should re-sync when the document becomes visible', () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW);
it('should reuse the cached server time for callers within the 2s window without a new request', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
service.startPeriodicSync(60_000);
document.dispatchEvent(new Event('visibilitychange'));
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
expectServerTimeRequest().flush(`${SERVER_NOW + 30_000}`);
expect(service.clockOffsetMs).toBe(30_000);
});
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
httpMock.expectNone((req) => req.url === appRootUrl());
it('should debounce repeated visibility-triggered syncs', () => {
spyOn(Date, 'now').and.returnValues(
SERVER_NOW,
SERVER_NOW,
SERVER_NOW,
SERVER_NOW + 5000,
SERVER_NOW + 31_000,
SERVER_NOW + 31_000,
SERVER_NOW + 31_000
);
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
service.startPeriodicSync(60_000);
it('should perform a new request once the 2s cache window has expired', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
document.dispatchEvent(new Event('visibilitychange'));
expectServerTimeRequest().flush(`${SERVER_NOW}`);
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
document.dispatchEvent(new Event('visibilitychange'));
httpMock.expectNone(() => true);
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
document.dispatchEvent(new Event('visibilitychange'));
expectServerTimeRequest().flush(`${SERVER_NOW + 31_000}`);
});
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
it('should remove the visibility listener when stopped', () => {
service.startPeriodicSync(60_000);
service.stopPeriodicSync();
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
document.dispatchEvent(new Event('visibilitychange'));
it('should not cache errors and let the next caller retry immediately with a new request', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const errors: string[] = [];
httpMock.expectNone(() => true);
});
});
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe({ error: (error: Error) => errors.push(error.message) });
expectAppRootTimeRequest().error(new ProgressEvent('error'));
describe('observability', () => {
let warnSpy: jasmine.Spy;
let debugSpy: jasmine.Spy;
expect(errors.length).toBe(1);
beforeEach(() => {
const logService = TestBed.inject(LogService);
warnSpy = spyOn(logService, 'warn');
debugSpy = spyOn(logService, 'debug');
});
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
it('should debug-log missing server time, invalid server time, and request failures', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW, SERVER_NOW);
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
const missing = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush('');
await missing;
const invalid = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush('not-a-valid-date\r\ninjected-line');
await invalid;
const failed = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().error(new ProgressEvent('error'));
await failed;
expect(debugSpy).toHaveBeenCalledTimes(3);
expect(debugSpy.calls.allArgs().some(([message]) => `${message}`.includes('\r') || `${message}`.includes('\n'))).toBeFalse();
expect(warnSpy).not.toHaveBeenCalled();
});
it('should warn-log unsupported URLs and implausible offsets', async () => {
configureApp({ serverTimeUrl: 'javascript:alert(1)' });
await firstValueFrom(service.syncClockOffset());
configureApp();
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush(`${SERVER_NOW + 60 * 60 * 1000}`);
await result;
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
describe('clock skew scenario matrix', () => {
describe('timeSync not configured', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should keep raw local time for ${scenario.description}`, async () => {
configureApp({ omitTimeSync: true });
const rawLocalNow = rawLocalInstantFor(scenario);
describe('time sync off', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should keep raw local time for ${description}`, async () => {
configureApp({ timeSync: false });
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
await firstValueFrom(service.syncClockOffset());
expect(service.getCorrectedNow()).toBe(rawLocalNow);
const result = await firstValueFrom(service.syncClockOffsetResult());
httpMock.expectNone(() => true);
expect(result.status).toBe('disabled');
expect(service.clockOffsetMs).toBe(0);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
describe('timeSync false', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should run the old raw-clock skew check for ${scenario.description}`, async () => {
configureApp({ timeSync: false });
const rawLocalNow = rawLocalInstantFor(scenario);
describe('time sync malfunction', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should keep raw local time for ${description} when server time cannot be fetched`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest(false));
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expectTimeSyncResult(await check, {
outOfSync: scenario.skewSeconds > MAX_ALLOWED_CLOCK_SKEW_IN_SEC,
timeOffsetInSec: scenario.skewSeconds,
localDateTimeISO: new Date(rawLocalNow).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
expect(await result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
describe('timeSync true but server time fails', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should fall back to raw local time for ${scenario.description}`, async () => {
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
it('should keep a previous trusted offset when a later sync malfunctions', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 120_000, SERVER_NOW + 120_000);
const sync = firstValueFrom(service.syncClockOffset());
expectAppRootTimeRequest().error(new ProgressEvent('error'));
await sync;
const synced = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
await synced;
expect(service.getCorrectedNow()).toBe(rawLocalNow);
const failed = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await failed).toEqual({
status: 'failed',
appliedOffsetMs: 60_000,
previousOffsetMs: 60_000,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 180_000);
});
});
describe('timeSync true and server time succeeds', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should correct ${scenario.description} and report the clock as in sync`, async () => {
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
describe('time sync on', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should correct ${description} to server time`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
const expectedOffset = expectedOffsetFor(rawLocalNow);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
const result = await syncWithServerTime(rawLocalNow);
expectTimeSyncResult(await check, {
outOfSync: false,
timeOffsetInSec: 0,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
expect(result).toEqual({
status: 'synced',
appliedOffsetMs: expectedOffset,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: rawLocalNow
});
expect(service.clockOffsetMs).toBe(expectedOffset);
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
expect(service.getCorrectedNow()).toBe(rawLocalNow + expectedOffsetInMsFor(rawLocalNow));
});
});
});
});
describe('debug logging', () => {
describe('syncClockOffset', () => {
it('should log time sync debug information when showDebugInformation is true', async () => {
configureApp({ timeSync: true, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
describe('offset clamp', () => {
[
{ id: 'TC-27', skewSeconds: 599, direction: 'behind' as const },
{ id: 'TC-28', skewSeconds: 599, direction: 'ahead' as const },
{ id: 'TC-29', skewSeconds: 600, direction: 'behind' as const },
{ id: 'TC-30', skewSeconds: 600, direction: 'ahead' as const }
].forEach(({ id, skewSeconds, direction }) => {
it(`${id}: should apply an offset at or within the trust bound`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
await syncWithServerTime(rawLocalNow);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] syncClockOffset: offset set to'));
expect(service.clockOffsetMs).toBe(expectedOffsetFor(rawLocalNow));
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
});
it('should not log time sync debug information when showDebugInformation is false', async () => {
configureApp({ timeSync: true, showDebugInformation: false });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
[
{ id: 'TC-31', skewSeconds: 601, direction: 'behind' as const },
{ id: 'TC-32', skewSeconds: 601, direction: 'ahead' as const }
].forEach(({ id, skewSeconds, direction }) => {
it(`${id}: should reject an offset beyond the trust bound`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValues(rawLocalNow, rawLocalNow, rawLocalNow);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW}`);
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
});
it('should not log time sync debug information when timeSync is disabled even if showDebugInformation is true', async () => {
configureApp({ timeSync: false, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
});
});
describe('checkTimeSync', () => {
it('should log time sync debug information for checkTimeSync when showDebugInformation is true', async () => {
configureApp({ timeSync: true, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
await check;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
});
it('should log checkTimeSync debug information even when timeSync is disabled', async () => {
configureApp({ timeSync: false, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest(false));
await check;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
expect((await result).status).toBe('implausible-offset');
expect(service.clockOffsetMs).toBe(0);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
});
@@ -16,15 +16,12 @@
*/
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { OAuthLogger } from 'angular-oauth2-oidc';
import { Observable, ReplaySubject, defer, of, throwError, timer } from 'rxjs';
import { catchError, map, share, timeout } from 'rxjs/operators';
import { Injectable, NgZone, inject } from '@angular/core';
import { interval, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, finalize, map, shareReplay, switchMap, timeout } from 'rxjs/operators';
import { LogService } from '../../common/services/log.service';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
const SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME = 'adf-time-sync';
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
export interface TimeSync {
outOfSync: boolean;
timeOutOfSyncInSec?: number;
@@ -32,36 +29,99 @@ export interface TimeSync {
serverDateTimeISO: string;
}
/**
* Emitted when a measured clock offset is rejected for exceeding `maxAllowedOffsetMs`.
* Consumers can subscribe to `implausibleOffsetDetected$` and forward this to central
* telemetry to detect misconfigured time sources or server-time tampering across the fleet.
*/
export interface ImplausibleClockOffsetEvent {
measuredOffsetMs: number;
maxAllowedOffsetMs: number;
}
export type ClockSyncStatus = 'disabled' | 'synced' | 'failed' | 'missing-server-time' | 'invalid-server-time' | 'implausible-offset';
export interface ClockSyncResult {
status: ClockSyncStatus;
appliedOffsetMs: number;
previousOffsetMs: number;
hasSuccessfulSync: boolean;
lastSuccessfulSyncAtMs?: number;
}
/** Timestamps captured during a single server-time request and used to calculate clock offset. */
interface ClockSyncMeasurement {
serverTimeInMs: number;
startMonotonicTimeInMs: number;
endTimeInMs: number;
}
/** Default interval for periodic clock re-sync (5 minutes). */
const DEFAULT_PERIODIC_SYNC_INTERVAL_MS = 5 * 60 * 1000;
/**
* Default upper bound, in milliseconds, for a clock offset that will be trusted (10 minutes).
* Offsets larger than this are treated as implausible and ignored so that one time response
* cannot arbitrarily extend client-side token validity. See `maxAllowedOffsetMs`.
*/
const DEFAULT_MAX_ALLOWED_OFFSET_MS = 10 * 60 * 1000;
/** Minimum delay between visibility-triggered re-syncs (30 seconds) to avoid request storms. */
const VISIBILITY_SYNC_DEBOUNCE_MS = 30 * 1000;
/** Timeout applied to the time-sync request (5 seconds). */
const SYNC_REQUEST_TIMEOUT_MS = 5000;
@Injectable({
providedIn: 'root'
})
export class TimeSyncService {
private readonly _http = inject(HttpClient);
private readonly _appConfigService = inject(AppConfigService);
private readonly _oauthLogger = inject(OAuthLogger, { optional: true });
private readonly _ngZone = inject(NgZone);
private readonly _logService = inject(LogService);
private readonly _appConfig = inject(AppConfigService);
/**
* Shared, self-expiring server-time request.
*
* OAuth-event-driven callers ask for the server time in quick succession, which
* previously fired one HTTP request per caller. `share` collapses concurrent
* subscribers onto a single in-flight request and replays the resolved value to
* any caller for the next {@link SERVER_TIME_CACHE_WINDOW_IN_MS}; after the window
* elapses the next subscriber triggers a fresh request. `defer` rebuilds the
* request options (including a new cache-busting timestamp) for every genuinely
* new request. Errors are never cached, so the next caller retries immediately.
* The signed offset in milliseconds between the adjusted server time and the local clock.
* Positive means the local clock is behind the server; negative means it is ahead.
* Defaults to 0 until `syncClockOffset` has successfully run.
*/
private readonly serverTime$: Observable<number> = defer(() => this.requestServerTime()).pipe(
share({
connector: () => new ReplaySubject<number>(1),
resetOnError: true,
resetOnComplete: () => timer(SERVER_TIME_CACHE_WINDOW_IN_MS),
resetOnRefCountZero: false
})
);
clockOffsetMs = 0;
private clockOffsetMs = 0;
/**
* Maximum magnitude, in milliseconds, of a measured clock offset that will be trusted and
* applied. Any measured offset whose absolute value exceeds this bound is treated as
* implausible (a hostile / misconfigured server-time response or an unreliable measurement) and is
* ignored, so a single response can never arbitrarily extend client-side token validity.
* Defaults to 10 minutes.
*/
maxAllowedOffsetMs = DEFAULT_MAX_ALLOWED_OFFSET_MS;
private readonly _implausibleOffsetDetected = new Subject<ImplausibleClockOffsetEvent>();
/**
* Emits whenever a measured offset is rejected for exceeding `maxAllowedOffsetMs`.
* Surface this to monitoring/telemetry to detect potential server-time tampering or a
* misconfigured time source; the client console alone is not a reliable security signal.
*/
readonly implausibleOffsetDetected$ = this._implausibleOffsetDetected.asObservable();
private _periodicSyncSubscription: Subscription | null = null;
private _visibilityChangeHandler: (() => void) | null = null;
private _lastSyncAtMs = 0;
private _lastSuccessfulSyncAtMs: number | null = null;
private _inFlightSync$: Observable<ClockSyncResult> | null = null;
/**
* Returns the current local time corrected by the last measured clock offset.
* Use this instead of `Date.now()` when evaluating token expiration to avoid
* false positives caused by VM / Citrix clock drift.
*
* When the feature is disabled via AppConfig, this returns the raw local time so the
* consuming application behaves exactly as it did before clock-skew correction existed.
*
* @returns corrected timestamp in milliseconds
*/
getCorrectedNow(): number {
if (!this.isEnabled()) {
return Date.now();
@@ -70,145 +130,435 @@ export class TimeSyncService {
return Date.now() + this.clockOffsetMs;
}
/**
* Synchronizes the local correction offset from `serverTimeUrl`.
*
* When `oauth2.timeSync` is false or missing, no request is made and callers keep using the raw
* local clock. When it is true, `serverTimeUrl` must return a server-generated time string in
* the response body. Any missing URL, failed request, missing/invalid body, or rejected offset
* leaves the current offset unchanged; with no previous successful sync this is `0`, which is
* the old raw-clock behavior.
*
* @returns Observable that completes after the offset decision has been made
*/
syncClockOffset(): Observable<void> {
if (!this.isEnabled()) {
return of(void 0);
}
const startTime = Date.now();
let serverTime$: Observable<number>;
try {
serverTime$ = this.getServerTime();
} catch {
this.clockOffsetMs = 0;
return of(void 0);
}
return serverTime$.pipe(
map((serverTimeResponse: number) => {
const localCurrentTimeInMs = Date.now();
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
this.debug(
`syncClockOffset: offset set to ${this.clockOffsetMs}ms ` +
`(server=${new Date(adjustedServerTimeInMs).toISOString()}, local=${new Date(localCurrentTimeInMs).toISOString()})`
);
}),
catchError(() => {
this.clockOffsetMs = 0;
this.debug('syncClockOffset: failed to reach server, offset reset to 0');
return of(void 0);
})
);
return this.syncClockOffsetResult().pipe(map(() => void 0));
}
/**
* Syncs the clock offset and reports whether the current offset came from a fresh successful
* measurement, a previous trusted sync, or the old raw-clock path.
*
* @returns Observable that emits the sync outcome after the offset decision has been made
*/
syncClockOffsetResult(): Observable<ClockSyncResult> {
return new Observable<ClockSyncResult>((subscriber) => {
const previousOffsetMs = this.clockOffsetMs;
if (!this.isEnabled()) {
subscriber.next(this.createClockSyncResult('disabled', 0, previousOffsetMs));
subscriber.complete();
return undefined;
}
if (!this._inFlightSync$) {
this._inFlightSync$ = this.createClockSyncRequest(previousOffsetMs);
}
const subscription = this._inFlightSync$.subscribe(subscriber);
return () => subscription.unsubscribe();
});
}
/**
* Checks the time synchronisation status using the stored clock offset.
*
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
* @returns An Observable that emits a TimeSync result.
*/
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
const startTime = Date.now();
const localCurrentTimeInMs = Date.now();
const clockOffsetMs = this.isEnabled() ? this.clockOffsetMs : 0;
const adjustedServerTimeInMs = localCurrentTimeInMs + clockOffsetMs;
const timeOffsetInMs = Math.abs(clockOffsetMs);
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
return this.getServerTime().pipe(
map((serverTimeResponse: number) => {
const localCurrentTimeInMs = Date.now();
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
let localTimeInMs = localCurrentTimeInMs;
return of({
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
timeOutOfSyncInSec: timeOffsetInMs / 1000,
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
});
}
if (this.isEnabled()) {
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs;
/**
* Checks if the local time is out of sync with the server time.
*
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
* @returns An Observable that emits a boolean indicating whether the local time is out of sync.
*/
isLocalTimeOutOfSync(maxAllowedClockSkewInSec: number): Observable<boolean> {
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(map((sync) => sync.outOfSync));
}
/**
* Starts periodic re-synchronization of the clock offset to protect against
* progressive clock drift during a user session (common in Citrix/VM environments).
*
* Re-sync is triggered:
* - On a regular interval (default: every 5 minutes)
* - When the document becomes visible again (e.g., Citrix session resumes after idle)
*
* @param intervalMs How often to re-sync in milliseconds (default: 5 minutes)
*/
startPeriodicSync(intervalMs: number = DEFAULT_PERIODIC_SYNC_INTERVAL_MS): void {
if (!this.isEnabled()) {
return;
}
this.stopPeriodicSync();
this._ngZone.runOutsideAngular(() => {
this._periodicSyncSubscription = interval(intervalMs)
.pipe(switchMap(() => this.syncClockOffset()))
.subscribe();
this._visibilityChangeHandler = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
// Debounce rapid visibility toggles (and multiple resumes across tabs) so we
// do not issue a burst of redundant time-sync requests when a session resumes.
if (Date.now() - this._lastSyncAtMs < VISIBILITY_SYNC_DEBOUNCE_MS) {
return;
}
this.syncClockOffset().subscribe();
}
};
const timeOffsetInMs = Math.abs(localTimeInMs - adjustedServerTimeInMs);
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
const outOfSync = timeOffsetInMs > maxAllowedClockSkewInMs;
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this._visibilityChangeHandler);
}
});
}
this.debug(
`checkTimeSync: outOfSync=${outOfSync} ` +
`(local=${new Date(localTimeInMs).toISOString()}, server=${new Date(adjustedServerTimeInMs).toISOString()}, offset=${this.clockOffsetMs}ms)`
);
/**
* Stops the periodic clock re-synchronization and removes the visibility change listener.
*/
stopPeriodicSync(): void {
this._periodicSyncSubscription?.unsubscribe();
this._periodicSyncSubscription = null;
return {
outOfSync,
timeOffsetInSec: timeOffsetInMs / 1000,
localDateTimeISO: new Date(localTimeInMs).toISOString(),
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
};
}),
catchError((error) => throwError(() => new Error(error)))
if (this._visibilityChangeHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this._visibilityChangeHandler);
this._visibilityChangeHandler = null;
}
}
/**
* Whether clock-skew correction is enabled. Controlled by the optional `oauth2.timeSync`
* AppConfig flag so a consuming application can turn the feature on without code changes.
* The feature is opt-in: it defaults to `false` when the flag is absent, so an application
* behaves exactly as it did before clock-skew correction existed until it explicitly enables it.
*
* @returns true when the feature is enabled
*/
private isEnabled(): boolean {
return this._appConfig.oauth2.timeSync === true;
}
/**
* Builds the one HTTP request used by all overlapping sync callers.
* The request is shared until it completes, so multiple consumers waiting on the same sync do
* not issue duplicate calls to `serverTimeUrl`.
*
* @param previousOffsetMs offset that was active before this sync attempt started
* @returns shared sync result observable
*/
private createClockSyncRequest(previousOffsetMs: number): Observable<ClockSyncResult> {
const serverTimeUrl = this.getServerTimeUrl();
if (!serverTimeUrl) {
return this.createSharedResult(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
}
const startTimeInMs = Date.now();
const startMonotonicTimeInMs = this.getMonotonicNow();
this._lastSyncAtMs = startTimeInMs;
return this.requestServerTime(serverTimeUrl).pipe(
timeout(SYNC_REQUEST_TIMEOUT_MS),
map((response) => this.handleServerTimeResponse(response, startMonotonicTimeInMs, previousOffsetMs)),
catchError((error) => this.handleClockSyncRequestError(error, previousOffsetMs)),
finalize(() => (this._inFlightSync$ = null)),
shareReplay({ bufferSize: 1, refCount: false })
);
}
private getAdjustedServerTimeInMs(serverTimeResponse: number, startTime: number): number {
let serverTimeInMs: number;
const endTime = Date.now();
const roundTripTimeInMs = endTime - startTime;
/**
* Reads and validates `serverTimeUrl` from app.config.json.
* Relative same-origin URLs and absolute HTTP(S) URLs are supported. Unsupported schemes are
* ignored so configuration mistakes cannot trigger unexpected browser protocols.
*
* @returns configured server time URL, or null when time sync should fall back to raw clock
*/
private getServerTimeUrl(): string | null {
const serverTimeUrlValue = this._appConfig.get<unknown>(AppConfigValues.SERVER_TIME_URL);
const serverTimeUrl = typeof serverTimeUrlValue === 'string' ? serverTimeUrlValue.trim() : '';
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
if (!isServerTimeResponseInMs) {
serverTimeInMs = serverTimeResponse * 1000;
} else {
serverTimeInMs = serverTimeResponse;
if (!serverTimeUrl) {
this._logService.debug('TimeSyncService: serverTimeUrl is not configured; keeping the current clock offset.');
return null;
}
return serverTimeInMs + roundTripTimeInMs / 2;
if (this.isSupportedServerTimeUrl(serverTimeUrl)) {
return serverTimeUrl;
}
this._logService.warn(`TimeSyncService: ignoring unsupported serverTimeUrl "${this.sanitizeForLog(serverTimeUrl)}".`);
return null;
}
isEnabled(): boolean {
const timeSync = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_TIME_SYNC_ENABLED, false);
return timeSync === true || timeSync === 'true';
/**
* Allows relative same-origin URLs and absolute HTTP(S) URLs for `serverTimeUrl`.
* Protocol-relative URLs and unsupported schemes are rejected to avoid surprising browser
* behavior from configuration values.
*
* @param url configured server time URL
* @returns true when the URL can be requested by the time-sync service
*/
private isSupportedServerTimeUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || (!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith('//'));
}
private getServerTime(): Observable<number> {
return this.serverTime$;
/**
* Requests server time as plain text.
* The endpoint is expected to return the server instant in the response body; no response
* headers are required for the configured time-sync path.
*
* @param serverTimeUrl configured time source URL
* @returns HTTP response containing a server time body
*/
private requestServerTime(serverTimeUrl: string): Observable<HttpResponse<string>> {
return this._http.get(serverTimeUrl, { observe: 'response', responseType: 'text' });
}
private requestServerTime(): Observable<number> {
const requestOptions = {
observe: 'response' as const,
responseType: 'text' as const,
...(this.isEnabled() && {
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
},
params: {
[SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME]: Date.now().toString()
}
})
/**
* Turns the server response into a sync result.
* This method intentionally reads as a small pipeline: extract text, parse it, measure the
* offset, then apply or reject the correction.
*
* @param response HTTP response from `serverTimeUrl`
* @param startMonotonicTimeInMs monotonic timestamp captured before the request
* @param previousOffsetMs offset active before this sync attempt
* @returns sync result for the response
*/
private handleServerTimeResponse(response: HttpResponse<string>, startMonotonicTimeInMs: number, previousOffsetMs: number): ClockSyncResult {
const serverTime = this.extractServerTime(response);
if (serverTime === null) {
return this.createClockSyncResult('missing-server-time', this.clockOffsetMs, previousOffsetMs);
}
const serverTimeInMs = this.parseServerTime(serverTime);
if (isNaN(serverTimeInMs)) {
return this.createClockSyncResult('invalid-server-time', this.clockOffsetMs, previousOffsetMs);
}
return this.applyMeasuredOffset(
{
serverTimeInMs,
startMonotonicTimeInMs,
endTimeInMs: Date.now()
},
previousOffsetMs
);
}
/**
* Extracts the configured server-time value from the response body.
* Empty bodies are treated as a failed sync and leave the current offset unchanged.
*
* @param response HTTP response from `serverTimeUrl`
* @returns trimmed server time value, or null when the body is empty
*/
private extractServerTime(response: HttpResponse<string>): string | null {
const serverTime = response.body?.trim();
if (!serverTime) {
this._logService.debug('TimeSyncService: response has no server time value; keeping the current clock offset.');
return null;
}
return serverTime;
}
/**
* Applies a measured offset when it is within the configured trust bound.
* The round-trip duration is measured with a monotonic clock so wall-clock jumps during the
* request cannot distort the latency adjustment.
*
* @param measurement timestamps needed to calculate the offset
* @param previousOffsetMs offset active before this sync attempt
* @returns sync result after applying or rejecting the measured offset
*/
private applyMeasuredOffset(measurement: ClockSyncMeasurement, previousOffsetMs: number): ClockSyncResult {
const measuredOffsetMs = this.calculateOffsetMs(measurement);
if (this.isImplausibleOffset(measuredOffsetMs)) {
this.reportImplausibleOffset(measuredOffsetMs);
return this.createClockSyncResult('implausible-offset', this.clockOffsetMs, previousOffsetMs);
}
this.clockOffsetMs = measuredOffsetMs;
this._lastSuccessfulSyncAtMs = measurement.endTimeInMs;
return this.createClockSyncResult('synced', measuredOffsetMs, previousOffsetMs);
}
/**
* Calculates the signed difference between the local clock and adjusted server time.
* Positive means the client is behind the server; negative means it is ahead.
*
* @param measurement timestamps from the sync attempt
* @returns signed clock offset in milliseconds
*/
private calculateOffsetMs(measurement: ClockSyncMeasurement): number {
const roundTripTimeInMs = Math.max(0, this.getMonotonicNow() - measurement.startMonotonicTimeInMs);
const adjustedServerTimeInMs = measurement.serverTimeInMs + roundTripTimeInMs / 2;
return adjustedServerTimeInMs - measurement.endTimeInMs;
}
/**
* Checks whether the measured offset is too large to trust.
* Bounding the offset keeps a bad time source from extending client-side token validity by an
* arbitrary amount. The server remains the final authority for token validity.
*
* @param offsetMs measured signed offset in milliseconds
* @returns true when the offset must be rejected
*/
private isImplausibleOffset(offsetMs: number): boolean {
return Math.abs(offsetMs) > this.maxAllowedOffsetMs;
}
/**
* Emits and logs a rejected offset measurement.
* Consumers can subscribe to `implausibleOffsetDetected$` and forward the event to telemetry.
*
* @param offsetMs measured signed offset in milliseconds
*/
private reportImplausibleOffset(offsetMs: number): void {
const roundedOffsetMs = Math.round(offsetMs);
this._logService.warn(
`TimeSyncService: ignoring implausible clock offset of ${roundedOffsetMs} ms ` +
`(exceeds the maximum allowed ${this.maxAllowedOffsetMs} ms). Keeping the current clock offset.`
);
this._implausibleOffsetDetected.next({
measuredOffsetMs: roundedOffsetMs,
maxAllowedOffsetMs: this.maxAllowedOffsetMs
});
}
/**
* Parses supported server time response formats.
* Numeric values below `1_000_000_000_000` are treated as epoch seconds; larger numeric values
* are treated as epoch milliseconds. Non-numeric values are parsed as date strings.
*
* @param serverTime raw server time response body
* @returns parsed epoch milliseconds, or NaN when the value cannot be parsed
*/
private parseServerTime(serverTime: string): number {
const trimmedServerTime = serverTime.trim();
const numericServerTime = Number(trimmedServerTime);
if (trimmedServerTime && Number.isFinite(numericServerTime)) {
return Math.abs(numericServerTime) < 1_000_000_000_000 ? numericServerTime * 1000 : numericServerTime;
}
const parsedServerTime = new Date(trimmedServerTime).getTime();
if (isNaN(parsedServerTime)) {
this._logService.debug(
`TimeSyncService: unable to parse server time "${this.sanitizeForLog(serverTime)}"; keeping the current clock offset.`
);
}
return parsedServerTime;
}
/**
* Wraps an immediate result in the same shared/finalized shape as HTTP-backed sync requests.
* This keeps `_inFlightSync$` lifecycle handling identical for missing configuration and real
* network requests.
*
* @param result immediate sync result
* @returns shared result observable
*/
private createSharedResult(result: ClockSyncResult): Observable<ClockSyncResult> {
return of(result).pipe(
finalize(() => (this._inFlightSync$ = null)),
shareReplay({ bufferSize: 1, refCount: false })
);
}
/**
* Converts network, timeout, and unexpected HTTP errors into a non-throwing sync result.
* Failed syncs keep the current offset so the caller falls back to the internal clock when no
* earlier successful sync exists.
*
* @param error request error to log
* @param previousOffsetMs offset active before this sync attempt
* @returns failed sync result observable
*/
private handleClockSyncRequestError(error: unknown, previousOffsetMs: number): Observable<ClockSyncResult> {
this._logService.debug('TimeSyncService: failed to synchronise the clock offset; keeping the current clock offset.', error);
return of(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
}
/**
* Creates the normalized result object returned by sync callers.
* `hasSuccessfulSync` and `lastSuccessfulSyncAtMs` describe whether the current offset has ever
* come from a trusted server-time response.
*
* @param status outcome of this sync attempt
* @param appliedOffsetMs offset kept or applied after this attempt
* @param previousOffsetMs offset that was active before this attempt
* @returns normalized sync result
*/
private createClockSyncResult(status: ClockSyncStatus, appliedOffsetMs: number, previousOffsetMs: number): ClockSyncResult {
return {
status,
appliedOffsetMs,
previousOffsetMs,
hasSuccessfulSync: this._lastSuccessfulSyncAtMs !== null,
...(this._lastSuccessfulSyncAtMs === null ? {} : { lastSuccessfulSyncAtMs: this._lastSuccessfulSyncAtMs })
};
return this._http.get(this.getAppRootUrl(), requestOptions).pipe(
map((response: HttpResponse<string>) => this.getServerTimeFromDateHeader(response)),
timeout(5000),
catchError(() => throwError(() => new Error('Failed to get server time')))
);
}
private getServerTimeFromDateHeader(response: HttpResponse<string>): number {
const dateHeader = response.headers.get('date');
if (!dateHeader) {
throw new Error('Date header is not available.');
/**
* Reads a monotonic timestamp for request round-trip measurement.
* Falls back to `Date.now()` only in non-browser environments where `performance.now` is not
* available.
*
* @returns monotonic timestamp in milliseconds
*/
private getMonotonicNow(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return new Date(dateHeader).getTime();
return Date.now();
}
private getAppRootUrl(): string {
if (typeof window !== 'undefined') {
return window.location.href.split('?')[0].split('#')[0];
}
return '/';
}
private get showDebugInformation(): boolean {
const enableDebugInformation = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_SHOW_DEBUG_INFORMATION, false);
return enableDebugInformation === true || enableDebugInformation === 'true';
}
private debug(message: string): void {
if (this.showDebugInformation) {
this._oauthLogger?.info(`[TimeSync] ${message}`);
}
/**
* Sanitizes an untrusted, configuration-controlled or server-controlled value before
* it is written to a log. Strips control characters (including CR/LF) to prevent log forging
* if the log bus is ever forwarded to a backend store, and caps the length to bound noise.
*
* @param value raw value to sanitize
* @returns a log-safe representation of the value
*/
private sanitizeForLog(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\u0000-\u001F\u007F]/g, ' ').slice(0, 100);
}
}
@@ -84,7 +84,7 @@
/>
@if (cardViewDateTimeControl.hasError('matDatepickerParse')) {
<mat-error>
<span class="adf-error-text">{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate }}</span>
{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate }}
</mat-error>
}
</mat-form-field>
+5 -2
View File
@@ -43,6 +43,7 @@ import { IconComponent } from './icon';
import { DynamicChipListComponent } from './dynamic-chip-list';
import { IdentityUserInfoComponent } from './identity-user-info';
import { UnsavedChangesDialogComponent } from './dialogs';
import { MaterialModule } from './material.module';
import { provideAppConfig } from './app-config/provide-app-config';
/**
@@ -82,7 +83,8 @@ import { provideAppConfig } from './app-config/provide-app-config';
...NOTIFICATION_HISTORY_DIRECTIVES,
...SEARCH_TEXT_INPUT_DIRECTIVES,
UnsavedChangesDialogComponent,
DynamicChipListComponent
DynamicChipListComponent,
MaterialModule
],
providers: [...CORE_PIPES],
exports: [
@@ -110,7 +112,8 @@ import { provideAppConfig } from './app-config/provide-app-config';
...NOTIFICATION_HISTORY_DIRECTIVES,
...SEARCH_TEXT_INPUT_DIRECTIVES,
UnsavedChangesDialogComponent,
DynamicChipListComponent
DynamicChipListComponent,
MaterialModule
]
})
export class CoreModule {
@@ -38,12 +38,8 @@ export class InfiniteSelectScrollDirective implements AfterViewInit {
ngAfterViewInit() {
this.matSelect.openedChange.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((opened: boolean) => {
if (opened) {
setTimeout(() => {
if (this.matSelect.panel?.nativeElement) {
this.itemHeightToWaitBeforeLoadNext = this.getItemHeight() * (InfiniteSelectScrollDirective.MAX_ITEMS / 2);
this.matSelect.panel.nativeElement.addEventListener('scroll', (event: Event) => this.handleScrollEvent(event));
}
});
this.itemHeightToWaitBeforeLoadNext = this.getItemHeight() * (InfiniteSelectScrollDirective.MAX_ITEMS / 2);
this.matSelect.panel.nativeElement.addEventListener('scroll', (event: Event) => this.handleScrollEvent(event));
}
});
}
@@ -60,7 +56,6 @@ export class InfiniteSelectScrollDirective implements AfterViewInit {
}
private getItemHeight(): number {
const panelElement = this.matSelect.panel?.nativeElement;
return panelElement ? parseFloat(getComputedStyle(panelElement).fontSize || '0') * SELECT_ITEM_HEIGHT_EM : 0;
return parseFloat(getComputedStyle(this.matSelect.panel.nativeElement).fontSize || '0') * SELECT_ITEM_HEIGHT_EM;
}
}
@@ -39,10 +39,6 @@
}
.adf-container-widget {
.adf-form-field-input:not(.adf-inplace-input-mat-form-field, .adf-people-cloud, .adf-cloud-group) {
margin-bottom: 35px;
}
.adf-grid-list {
display: grid;
@@ -392,8 +392,8 @@ describe('Form Renderer Component', () => {
fixture.detectChanges();
await fixture.whenStable();
expectElementToBeHidden(testingUtils, 'Number2');
const errorText = testingUtils.getByCSS('#field-Number1-container .adf-error-text').nativeElement;
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
const errorWidgetText = testingUtils.getByCSS('#field-Number1-container error-widget .adf-error-text').nativeElement;
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
});
@@ -569,16 +569,16 @@ describe('Form Renderer Component', () => {
fixture.detectChanges();
await fixture.whenStable();
expectElementToBeInvalid(testingUtils, 'Number0x8cbv');
let errorText = testingUtils.getByCSS('#field-Number0x8cbv-container .adf-error-text').nativeElement;
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
let errorWidgetText = testingUtils.getByCSS('#field-Number0x8cbv-container error-widget .adf-error-text').nativeElement;
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
typeIntoInput(testingUtils, '#Number0x8cbv', '?');
fixture.detectChanges();
await fixture.whenStable();
expectElementToBeInvalid(testingUtils, 'Number0x8cbv');
errorText = testingUtils.getByCSS('#field-Number0x8cbv-container .adf-error-text').nativeElement;
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
errorWidgetText = testingUtils.getByCSS('#field-Number0x8cbv-container error-widget .adf-error-text').nativeElement;
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.INVALID_NUMBER`);
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without mandatory field');
typeIntoInput(testingUtils, '#Number0x8cbv', '-5');
@@ -600,8 +600,8 @@ describe('Form Renderer Component', () => {
await fixture.whenStable();
expectElementToBeInvalid(testingUtils, 'Number0him2z');
let errorText = testingUtils.getByCSS('#field-Number0him2z-container .adf-error-text').nativeElement;
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_LESS_THAN`);
let errorWidgetText = testingUtils.getByCSS('#field-Number0him2z-container error-widget .adf-error-text').nativeElement;
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_LESS_THAN`);
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without valid field');
typeIntoInput(testingUtils, '#Number0him2z', '10');
@@ -621,8 +621,8 @@ describe('Form Renderer Component', () => {
await fixture.whenStable();
expectElementToBeInvalid(testingUtils, 'Number0him2z');
errorText = testingUtils.getByCSS('#field-Number0him2z-container .adf-error-text').nativeElement;
expect(errorText.textContent).toContain(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
errorWidgetText = testingUtils.getByCSS('#field-Number0him2z-container error-widget .adf-error-text').nativeElement;
expect(errorWidgetText.textContent).toBe(`FORM.FIELD.VALIDATOR.NOT_GREATER_THAN`);
expect(formRendererComponent.formDefinition.isValid).toBe(false, 'Form should not be valid without valid field');
});
@@ -27,17 +27,16 @@
[(ngModel)]="amountWidgetValue"
(ngModelChange)="onFieldChangedAmountWidget()"
[disabled]="field.readOnly"
[errorStateMatcher]="errorStateMatcher"
(focus)="amountWidgetOnFocus()"
(blur)="amountWidgetOnBlur()"
/>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
</mat-form-field>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
/>
</div>
</div>
</div>
@@ -1,6 +1,5 @@
/* stylelint-disable selector-class-pattern */
@use '../../../../styles/mat-selectors' as ms;
@use '../../../../styles/mixins' as mixins;
.adf {
&-amount-widget {
@@ -26,7 +25,3 @@
}
}
}
.adf-form-field-input .adf-error-icon {
@include mixins.adf-error-icon;
}
@@ -335,9 +335,8 @@ describe('AmountWidgetComponent - rendering', () => {
await input.setValue('gdfgdf');
expect(widget.field.isValid).toBe(false);
const formField = await testingUtils.getMatFormField();
const errors = await formField.getTextErrors();
expect(errors[0]).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
const errorWidget = testingUtils.getByCSS('error-widget .adf-error-text').nativeElement;
expect(errorWidget.textContent).toBe('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
});
it('[C309693] - Should be possible to set the Advanced Properties for Amount Widget', async () => {
@@ -373,14 +372,13 @@ describe('AmountWidgetComponent - rendering', () => {
await input.setValue('8');
expect(widget.field.isValid).toBe(false);
const formField = await testingUtils.getMatFormField();
let errors = await formField.getTextErrors();
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
let errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.NOT_LESS_THAN');
await input.setValue('99');
expect(widget.field.isValid).toBe(false);
errors = await formField.getTextErrors();
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.NOT_GREATER_THAN');
errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.NOT_GREATER_THAN');
await input.setValue('80');
expect(widget.field.isValid).toBe(true);
@@ -390,8 +388,8 @@ describe('AmountWidgetComponent - rendering', () => {
await input.setValue('incorrect format');
expect(widget.field.isValid).toBe(false);
errors = await formField.getTextErrors();
expect(errors[0].trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
errorMessage = testingUtils.getByCSS('.adf-error-text').nativeElement;
expect(errorMessage.textContent.trim()).toContain('FORM.FIELD.VALIDATOR.INVALID_NUMBER');
});
describe('when form model has left labels', () => {
@@ -19,12 +19,11 @@
import { CurrencyPipe, NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation, InjectionToken, inject, DestroyRef } from '@angular/core';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { ErrorWidgetComponent } from '../error/error.component';
import { WidgetComponent } from '../widget.component';
import { filter, isObservable, Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -53,7 +52,7 @@ export const ADF_AMOUNT_SETTINGS = new InjectionToken<Observable<AmountWidgetSet
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [MatFormFieldModule, MatInputModule, FormsModule, TranslatePipe, NgIf, MatIconModule],
imports: [MatFormFieldModule, MatInputModule, FormsModule, ErrorWidgetComponent, TranslatePipe, NgIf],
providers: [CurrencyPipe],
encapsulation: ViewEncapsulation.None
})
@@ -70,13 +69,11 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
currencyDisplay: string | boolean = 'symbol';
decimalProperty: string;
enableDisplayBasedOnLocale: boolean;
errorStateMatcher: ErrorStateMatcher;
isInputInFocus = false;
locale: string;
notShowDecimalDigits = '1.0-0';
showDecimalDigits = '1.2-2';
showReadonlyPlaceholder: boolean;
translateParameters: Record<string, string> = {};
valueAsNumber: number;
get placeholder(): string {
@@ -120,8 +117,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
}
this.subscribeToFieldChanges();
this.setInitialValues();
this.initErrorStateMatcher();
this.updateTranslateParameters();
}
}
@@ -143,7 +138,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
}
}
this.markAsTouched();
this.updateTranslateParameters();
}
amountWidgetOnFocus(): void {
@@ -162,8 +156,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
onFieldChangedAmountWidget(): void {
this.field.value = this.amountWidgetValue;
super.onFieldChanged(this.field);
this.markAsTouched();
this.updateTranslateParameters();
}
setInitialValues(): void {
@@ -188,7 +180,6 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
} else if (!this.isInputInFocus) {
this.amountWidgetValue = ev.field.value;
}
this.updateTranslateParameters();
});
}
@@ -201,19 +192,4 @@ export class AmountWidgetComponent extends WidgetComponent implements OnInit {
this.enableDisplayBasedOnLocale = data?.enableDisplayBasedOnLocale ?? false;
this.showReadonlyPlaceholder = data?.showReadonlyPlaceholder;
}
private initErrorStateMatcher(): void {
this.errorStateMatcher = {
isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean =>
!!this.field.validationSummary?.message || (this.isInvalidFieldRequired() && this.isTouched())
};
}
private updateTranslateParameters(): void {
if (this.field?.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
@@ -57,13 +57,6 @@ export class FormFieldTypes {
static READONLY_TYPES: string[] = [FormFieldTypes.HYPERLINK, FormFieldTypes.DISPLAY_VALUE, FormFieldTypes.READONLY_TEXT, FormFieldTypes.GROUP];
static readonly DISPLAY_TEXT_TYPES: string[] = [
FormFieldTypes.TEXT,
FormFieldTypes.MULTILINE_TEXT,
FormFieldTypes.READONLY_TEXT,
FormFieldTypes.DISPLAY_VALUE
];
static VALIDATABLE_TYPES: string[] = [FormFieldTypes.DISPLAY_EXTERNAL_PROPERTY];
static REACTIVE_TYPES: string[] = [FormFieldTypes.DATE, FormFieldTypes.DATETIME, FormFieldTypes.DROPDOWN];
@@ -74,10 +67,6 @@ export class FormFieldTypes {
return FormFieldTypes.READONLY_TYPES.includes(type);
}
static isDisplayTextType(type: string): boolean {
return FormFieldTypes.DISPLAY_TEXT_TYPES.includes(type);
}
static isValidatableType(type: string): boolean {
return FormFieldTypes.VALIDATABLE_TYPES.includes(type);
}
@@ -19,7 +19,6 @@ import { ContainerModel } from './container.model';
import { ErrorMessageModel } from './error-message.model';
import { FormFieldTypes } from './form-field-types';
import {
DEFAULT_TEXT_MAX_LENGTH,
FixedValueFieldValidator,
MaxLengthFieldValidator,
MaxValueFieldValidator,
@@ -682,42 +681,6 @@ describe('FormFieldValidator', () => {
expect(validator.isSupported(field)).toBe(true);
});
it('should support text field when fallback maxLength is defined', () => {
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.TEXT
});
expect(fallbackValidator.isSupported(field)).toBe(true);
expect(fallbackValidator.getMaxLength(field)).toBe(DEFAULT_TEXT_MAX_LENGTH);
});
it('should validate text field against fallback maxLength when maxLength is not configured', () => {
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.TEXT,
value: 'a'.repeat(DEFAULT_TEXT_MAX_LENGTH + 1)
});
field.validationSummary = new ErrorMessageModel();
expect(fallbackValidator.validate(field)).toBe(false);
expect(field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.NO_LONGER_THAN');
expect(field.validationSummary.attributes.get('maxLength')).toBe(DEFAULT_TEXT_MAX_LENGTH.toLocaleString());
});
it('should use configured maxLength instead of fallback maxLength', () => {
const fallbackValidator = new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH);
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.TEXT,
maxLength: 3,
value: '1234'
});
field.validationSummary = new ErrorMessageModel();
expect(fallbackValidator.validate(field)).toBe(false);
expect(field.validationSummary.attributes.get('maxLength')).toBe('3');
});
it('should allow empty values', () => {
const field = new FormFieldModel(new FormModel(), {
type: FormFieldTypes.TEXT,
@@ -21,8 +21,6 @@ import { FormFieldTypes } from './form-field-types';
import { isNumberValue } from './form-field-utils';
import { FormFieldModel } from './form-field.model';
export const DEFAULT_TEXT_MAX_LENGTH = 1024;
export interface FormFieldValidator {
isSupported(field: FormFieldModel): boolean;
validate(field: FormFieldModel): boolean;
@@ -137,8 +135,7 @@ export class MinLengthFieldValidator implements FormFieldValidator {
export class MaxLengthFieldValidator implements FormFieldValidator {
constructor(
private readonly supportedTypes: FormFieldTypes[] = [FormFieldTypes.TEXT, FormFieldTypes.MULTILINE_TEXT],
private readonly maxLength?: number,
private readonly fallbackMaxLength?: number
private readonly maxLength?: number
) {}
isSupported(field: FormFieldModel): boolean {
@@ -161,11 +158,7 @@ export class MaxLengthFieldValidator implements FormFieldValidator {
}
getMaxLength(field: FormFieldModel): number | undefined {
if (this.maxLength !== undefined) {
return this.maxLength;
}
return field.maxLength > 0 ? field.maxLength : this.fallbackMaxLength;
return this.maxLength ?? field.maxLength;
}
}
@@ -324,8 +317,7 @@ export const FORM_FIELD_VALIDATORS = [
new RequiredFieldValidator(),
new NumberFieldValidator(),
new MinLengthFieldValidator(),
new MaxLengthFieldValidator([FormFieldTypes.TEXT], undefined, DEFAULT_TEXT_MAX_LENGTH),
new MaxLengthFieldValidator([FormFieldTypes.MULTILINE_TEXT]),
new MaxLengthFieldValidator(),
new MaxLengthFieldValidator([FormFieldTypes.NUMBER], 10),
new MinValueFieldValidator(),
new MaxValueFieldValidator(),
@@ -16,8 +16,6 @@
*/
import { DateFnsUtils } from '../../../../common';
import { FormRulesEvent } from '../../../events/form-rules.event';
import { firstValueFrom, map, Subject, take, timeout } from 'rxjs';
import { FormFieldTypes } from './form-field-types';
import { RequiredFieldValidator } from './form-field-validator';
import { FormFieldModel } from './form-field.model';
@@ -1543,17 +1541,6 @@ describe('FormFieldModel', () => {
});
describe('add row', () => {
const assignFormRulesEventSubject = (): Subject<FormRulesEvent> => {
const formRulesEvent = new Subject<FormRulesEvent>();
(field.form as any).formService = {
formRulesEvent,
validateForm: new Subject(),
validateFormField: new Subject(),
formFieldValueChanged: new Subject()
};
return formRulesEvent;
};
it('should add row if allowed by limit param', () => {
expect(field.rows.length).toBe(2);
@@ -1581,56 +1568,18 @@ describe('FormFieldModel', () => {
expect(field.rows.length).toBe(5);
});
it('should call onRepeatableSectionRowCountChanged', () => {
spyOn(field.form, 'onRepeatableSectionRowCountChanged').and.callThrough();
it('should call onRepeatableSectionChanged', () => {
spyOn(field.form, 'onRepeatableSectionChanged').and.callThrough();
expect(field.form.onRepeatableSectionRowCountChanged).not.toHaveBeenCalled();
expect(field.form.onRepeatableSectionChanged).not.toHaveBeenCalled();
field.addRow(field.fields, form);
expect(field.form.onRepeatableSectionRowCountChanged).toHaveBeenCalledWith(field);
});
it('should emit onRowCountChanged via formService.formRulesEvent', async () => {
const formRulesEvent = assignFormRulesEventSubject();
const emissionPromise = firstValueFrom(formRulesEvent);
field.addRow(field.fields, form);
const emittedEvent = await emissionPromise;
expect(emittedEvent.type).toBe('onRowCountChanged');
expect(emittedEvent.field).toBe(field);
});
it('should NOT emit onRowCountChanged when add row is not allowed', async () => {
const formRulesEvent = assignFormRulesEventSubject();
field.addRow(field.fields, form);
field.addRow(field.fields, form);
field.addRow(field.fields, form);
expect(field.rows.length).toBe(5);
const emissionPromise = firstValueFrom(formRulesEvent.pipe(timeout(50)));
field.addRow(field.fields, form);
await expectAsync(emissionPromise).toBeRejected();
expect(field.rows.length).toBe(5);
expect(field.form.onRepeatableSectionChanged).toHaveBeenCalled();
});
});
describe('remove row', () => {
const assignFormRulesEventSubject = (): Subject<FormRulesEvent> => {
const formRulesEvent = new Subject<FormRulesEvent>();
(field.form as any).formService = {
formRulesEvent,
validateForm: new Subject(),
validateFormField: new Subject(),
formFieldValueChanged: new Subject()
};
return formRulesEvent;
};
it('should remove row if target index exists', () => {
expect(field.rows.length).toBe(2);
@@ -1697,77 +1646,14 @@ describe('FormFieldModel', () => {
expect(field.form.onFormFieldChanged).not.toHaveBeenCalled();
});
it('should call onRepeatableSectionRowCountChanged', () => {
spyOn(field.form, 'onRepeatableSectionRowCountChanged').and.callThrough();
it('should call onRepeatableSectionChanged', () => {
spyOn(field.form, 'onRepeatableSectionChanged').and.callThrough();
expect(field.form.onRepeatableSectionRowCountChanged).not.toHaveBeenCalled();
expect(field.form.onRepeatableSectionChanged).not.toHaveBeenCalled();
field.removeRow(1);
expect(field.form.onRepeatableSectionRowCountChanged).toHaveBeenCalledWith(field);
});
it('should emit onRowCountChanged via formService.formRulesEvent', async () => {
const formRulesEvent = assignFormRulesEventSubject();
const emissionPromise = firstValueFrom(formRulesEvent);
field.removeRow(1);
const emittedEvent = await emissionPromise;
expect(emittedEvent.type).toBe('onRowCountChanged');
expect(emittedEvent.field).toBe(field);
});
it('should update form values before emitting onRowCountChanged', async () => {
const formRulesEvent = assignFormRulesEventSubject();
form.values[field.id] = [
{
Text0wwp7n: 'mock-1',
Integer0rzkwq: 1
},
{
Text0wwp7n: 'mock-2',
Integer0rzkwq: 2
}
];
const valuesLengthAtEmissionPromise = firstValueFrom(
formRulesEvent.pipe(
take(1),
map(() => form.values[field.id].length)
)
);
field.removeRow(1);
const valuesLengthAtEmission = await valuesLengthAtEmissionPromise;
expect(valuesLengthAtEmission).toBe(1);
expect(form.values[field.id]).toEqual([
{
Text0wwp7n: 'mock-1',
Integer0rzkwq: 1
}
]);
});
it('should emit onRowCountChanged even when form values do not contain section id', async () => {
const formRulesEvent = assignFormRulesEventSubject();
form.values = {};
const emissionPromise = firstValueFrom(formRulesEvent);
field.removeRow(1);
const emittedEvent = await emissionPromise;
expect(emittedEvent.type).toBe('onRowCountChanged');
});
it('should NOT emit onRowCountChanged when remove row target index does not exist', async () => {
const formRulesEvent = assignFormRulesEventSubject();
const emissionPromise = firstValueFrom(formRulesEvent.pipe(timeout(50)));
field.removeRow(2);
await expectAsync(emissionPromise).toBeRejected();
expect(field.form.onRepeatableSectionChanged).toHaveBeenCalled();
});
});
@@ -1937,25 +1823,3 @@ describe('FormFieldModel', () => {
});
});
});
describe('FormFieldTypes', () => {
describe('isDisplayTextType', () => {
it('should return true for text, multi-line text, readonly text and display value types', () => {
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.TEXT)).toBe(true);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.MULTILINE_TEXT)).toBe(true);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.READONLY_TEXT)).toBe(true);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.DISPLAY_VALUE)).toBe(true);
});
it('should return false for typed source field types', () => {
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.PEOPLE)).toBe(false);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.FUNCTIONAL_GROUP)).toBe(false);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.DROPDOWN)).toBe(false);
expect(FormFieldTypes.isDisplayTextType(FormFieldTypes.RADIO_BUTTONS)).toBe(false);
});
it('should return false for an unknown type', () => {
expect(FormFieldTypes.isDisplayTextType('unknown-type')).toBe(false);
});
});
});
@@ -470,7 +470,7 @@ export class FormFieldModel extends FormWidgetModel {
}
this.rows.push(this.createRow(fields, form, this.rows.length));
this.form.onRepeatableSectionRowCountChanged(this);
this.form.onRepeatableSectionChanged();
}
private shouldAddRow(): boolean {
@@ -484,17 +484,14 @@ export class FormFieldModel extends FormWidgetModel {
this.rows.splice(index, 1);
this.updateChildrenFieldsRowIndex();
this.form.onRepeatableSectionChanged();
const hasSectionValues = !!this.form.values[this.id];
if (hasSectionValues) {
this.form.values[this.id].splice(index, 1);
if (!this.form.values[this.id]) {
return;
}
this.form.onRepeatableSectionRowCountChanged(this);
if (hasSectionValues) {
this.form.onFormFieldChanged(this);
}
this.form.values[this.id].splice(index, 1);
this.form.onFormFieldChanged(this);
}
private shouldRemoveRow(index: number): boolean {
@@ -16,7 +16,6 @@
*/
import { FormFieldEvent } from '../../../events/form-field.event';
import { FormRulesEvent } from '../../../events/form-rules.event';
import { ValidateFormFieldEvent } from '../../../events/validate-form-field.event';
import { ValidateFormEvent } from '../../../events/validate-form.event';
import { ContainerModel } from './container.model';
@@ -155,11 +154,6 @@ export class FormModel implements ProcessFormModel {
this.fieldsCache = this.getFormFields([], true);
}
onRepeatableSectionRowCountChanged(sectionField: FormFieldModel): void {
this.onRepeatableSectionChanged();
this.formService?.formRulesEvent?.next(new FormRulesEvent('onRowCountChanged', new FormFieldEvent(this, sectionField)));
}
/**
* Validates entire form and all form fields.
*/
@@ -37,12 +37,9 @@
[touchUi]="true"
[timeInterval]="5"
[disabled]="field.readOnly" />
@if (datetimeInputControl.invalid && datetimeInputControl.touched && field.validationSummary?.message) {
<mat-error>
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text">{{ field.validationSummary.message | translate:translateParameters }}</span>
</mat-error>
}
</mat-form-field>
<div class="adf-error-messages-container">
<error-widget *ngIf="datetimeInputControl.invalid && datetimeInputControl.touched" [error]="field.validationSummary" />
</div>
</div>
</div>
@@ -1,6 +1,5 @@
/* stylelint-disable selector-class-pattern */
@use '../../../../styles/mat-selectors' as ms;
@use '../../../../styles/mixins' as mixins;
.adf {
&-date-time-widget {
@@ -24,10 +23,6 @@
}
}
.adf-form-field-input .adf-error-icon {
@include mixins.adf-error-icon;
}
#{ms.$mat-datetimepicker-toggle} {
color: var(--mat-sys-on-surface);
}
@@ -22,12 +22,12 @@ import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angul
import { FormControl, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { DatetimeAdapter, MAT_DATETIME_FORMATS, MatDatetimepickerModule } from '@mat-datetimepicker/core';
import { TranslatePipe } from '@ngx-translate/core';
import { ADF_DATE_FORMATS, ADF_DATETIME_FORMATS, AdfDateFnsAdapter, AdfDateTimeFnsAdapter, DateFnsUtils } from '../../../../common';
import { FormService } from '../../../services/form.service';
import { ErrorWidgetComponent } from '../error/error.component';
import { WidgetComponent } from '../widget.component';
import { ErrorMessageModel } from '../core/error-message.model';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -46,14 +46,13 @@ import { ReactiveFormWidget } from '../reactive-widget.interface';
host: {
'(click)': 'event($event)'
},
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, MatDatetimepickerModule, ReactiveFormsModule, MatIconModule],
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, MatDatetimepickerModule, ReactiveFormsModule, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class DateTimeWidgetComponent extends WidgetComponent implements OnInit, ReactiveFormWidget {
minDate: Date;
maxDate: Date;
datetimeInputControl: FormControl<Date> = new FormControl<Date>(null);
translateParameters: Record<string, string> = {};
public readonly formService = inject(FormService);
private readonly destroyRef = inject(DestroyRef);
@@ -117,15 +116,6 @@ export class DateTimeWidgetComponent extends WidgetComponent implements OnInit,
this.resetErrors();
this.field.markAsValid();
}
this.updateTranslateParameters();
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
private handleErrors(errors: ValidationErrors): void {
@@ -21,12 +21,11 @@
<mat-datepicker #datePicker
[startAt]="startAt"
[disabled]="field.readOnly" />
@if (dateInputControl.invalid && dateInputControl.touched) {
<mat-error>
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (dateInputControl.hasError('required')) {{{ 'FORM.FIELD.REQUIRED' | translate }}} @else if (dateInputControl.hasError('matDatepickerParse')) {{{ 'FORM.FIELD.VALIDATOR.INVALID_DATE_FORMAT' | translate: { format: field.dateDisplayFormat || field.defaultDateTimeFormat } }}} @else if (dateInputControl.hasError('matDatepickerMin')) {{{ 'FORM.FIELD.VALIDATOR.NOT_LESS_THAN' | translate: { minValue: formattedMinDate } }}} @else if (dateInputControl.hasError('matDatepickerMax')) {{{ 'FORM.FIELD.VALIDATOR.NOT_GREATER_THAN' | translate: { maxValue: formattedMaxDate } }}}</span>
</mat-error>
}
</mat-form-field>
<div class="adf-error-messages-container">
<error-widget
*ngIf="dateInputControl.invalid && dateInputControl.touched"
[error]="field.validationSummary"
/>
</div>
</div>
@@ -1,5 +1,3 @@
@use '../../../../styles/mixins' as mixins;
.adf {
&-widget {
&-container {
@@ -9,7 +7,3 @@
}
}
}
.adf-form-field-input .adf-error-icon {
@include mixins.adf-error-icon;
}
@@ -17,16 +17,17 @@
/* eslint-disable @angular-eslint/component-selector */
import { NgIf } from '@angular/common';
import { Component, DestroyRef, inject, OnInit, ViewEncapsulation } from '@angular/core';
import { FormControl, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { ADF_DATE_FORMATS, AdfDateFnsAdapter, DateFnsUtils, DEFAULT_DATE_FORMAT } from '../../../../common';
import { FormService } from '../../../services/form.service';
import { ErrorWidgetComponent } from '../error/error.component';
import { WidgetComponent } from '../widget.component';
import { ErrorMessageModel } from '../core/error-message.model';
import { parseISO } from 'date-fns';
@@ -52,7 +53,7 @@ import { ReactiveFormWidget } from '../reactive-widget.interface';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [MatFormFieldModule, TranslatePipe, MatInputModule, MatDatepickerModule, ReactiveFormsModule, MatIconModule],
imports: [MatFormFieldModule, TranslatePipe, MatInputModule, MatDatepickerModule, ReactiveFormsModule, ErrorWidgetComponent, NgIf],
encapsulation: ViewEncapsulation.None
})
export class DateWidgetComponent extends WidgetComponent implements OnInit, ReactiveFormWidget {
@@ -117,16 +118,6 @@ export class DateWidgetComponent extends WidgetComponent implements OnInit, Reac
this.onFieldChanged(this.field);
}
get formattedMinDate(): string {
const min = this.dateInputControl.errors?.matDatepickerMin?.min;
return min ? DateFnsUtils.formatDate(min, this.field.dateDisplayFormat).toLocaleUpperCase() : '';
}
get formattedMaxDate(): string {
const max = this.dateInputControl.errors?.matDatepickerMax?.max;
return max ? DateFnsUtils.formatDate(max, this.field.dateDisplayFormat).toLocaleUpperCase() : '';
}
private validateField(): void {
if (this.dateInputControl.invalid) {
this.handleErrors(this.dateInputControl.errors);
@@ -18,19 +18,19 @@
[id]="field.id"
[required]="field.required && field.isVisible"
[(ngModel)]="field.value"
(ngModelChange)="onDecimalFieldChanged()"
(ngModelChange)="onFieldChanged(field)"
[disabled]="field.readOnly"
[placeholder]="field.placeholder"
[title]="field.tooltip"
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()" />
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
}
(blur)="markAsTouched()" />
</mat-form-field>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
/>
</div>
</div>
</div>
@@ -1,5 +1,3 @@
@use '../../../../styles/mixins' as mixins;
.adf {
&-decimal-widget {
width: 100%;
@@ -11,7 +9,3 @@
}
}
}
.adf-form-field-input .adf-error-icon {
@include mixins.adf-error-icon;
}
@@ -16,13 +16,12 @@
*/
import { NgIf } from '@angular/common';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { FormsModule, FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { Component, ViewEncapsulation } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { ErrorWidgetComponent } from '../error/error.component';
import { WidgetComponent } from '../widget.component';
@Component({
@@ -40,39 +39,7 @@ import { WidgetComponent } from '../widget.component';
'(invalid)': 'event($event)',
'(select)': 'event($event)'
},
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule, MatIconModule],
imports: [NgIf, TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule, ErrorWidgetComponent],
encapsulation: ViewEncapsulation.None
})
export class DecimalWidgetComponent extends WidgetComponent implements OnInit {
errorStateMatcher: ErrorStateMatcher;
translateParameters: Record<string, string> = {};
ngOnInit(): void {
this.initErrorStateMatcher();
}
onBlur(): void {
this.markAsTouched();
this.updateTranslateParameters();
}
onDecimalFieldChanged(): void {
this.onFieldChanged(this.field);
this.updateTranslateParameters();
}
private initErrorStateMatcher(): void {
this.errorStateMatcher = {
isErrorState: (_control: UntypedFormControl | null, _form: FormGroupDirective | NgForm | null): boolean =>
!this.field.isValid && this.isTouched()
};
}
private updateTranslateParameters(): void {
if (this.field.validationSummary?.isActive()) {
this.translateParameters = this.field.validationSummary.getAttributesAsJsonObj();
} else {
this.translateParameters = {};
}
}
}
export class DecimalWidgetComponent extends WidgetComponent {}
@@ -7,7 +7,6 @@
<mat-form-field
floatPlaceholder="never"
class="adf-form-field-input"
[class.adf-has-counter]="field.maxLength > 0"
[floatLabel]="field.placeholder ? 'always' : null"
>
@if(field.name || field.required) {
@@ -21,24 +20,24 @@
rows="3"
[id]="field.id"
[required]="field.required"
[(ngModel)]="field.value"
(ngModelChange)="onMultilineTextFieldChanged()"
[ngModel]="displayValue"
(ngModelChange)="onValueChange($event)"
[disabled]="field.readOnly || readOnly"
[placeholder]="field.placeholder"
[title]="field.tooltip"
[errorStateMatcher]="errorStateMatcher"
(blur)="onBlur()"
(blur)="markAsTouched()"
>
</textarea>
@if (field.validationSummary?.message || (isInvalidFieldRequired() && isTouched())) {
<mat-error>
@if (field.maxLength > 0) {<span class="adf-multiline-counter-block">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</span>}
<mat-icon class="adf-error-icon">error_outline</mat-icon>
<span class="adf-error-text"
>@if (field.validationSummary?.message) {{{ field.validationSummary.message | translate:translateParameters }}} @else {{{ 'FORM.FIELD.REQUIRED' | translate }}}</span>
</mat-error>
} @else if (field.maxLength > 0) {
<mat-hint class="adf-multiline-hint">{{ field?.value?.length || 0 }}/{{ field.maxLength }}</mat-hint>
}
</mat-form-field>
<div *ngIf="field.maxLength > 0" class="adf-multiline-word-counter">
<span class="adf-multiline-word-counter-value">{{ displayValue?.length || 0 }}/{{ field.maxLength }}</span>
</div>
<div class="adf-error-messages-container">
<error-widget [error]="field.validationSummary" />
<error-widget
*ngIf="isInvalidFieldRequired() && isTouched()"
class="adf-multiline-required-message"
required="{{ 'FORM.FIELD.REQUIRED' | translate }}"
/>
</div>
</div>
@@ -1,12 +1,9 @@
@use '../../../../styles/mixins' as mixins;
.adf {
&-multiline-text-widget {
width: 100%;
display: flex;
align-items: flex-start;
flex-direction: column;
position: relative;
.adf-label {
top: 20px;
@@ -19,30 +16,23 @@
}
}
&-multiline-counter {
&-multiline-word-counter:has(.adf-multiline-word-counter-value) {
float: right;
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
}
&-multiline-counter-block {
display: block;
color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6));
}
&-multiline-hint {
margin-top: 7px;
margin-top: -20px;
min-height: 24px;
min-width: 1px;
font-size: var(--mat-sys-body-small-size);
line-height: 14px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.55, 0, 0.55, 0.2);
opacity: 1;
padding-top: 5px;
text-align: right;
padding-right: 2px;
padding-left: 0;
}
&-multiline-required-message {
display: flex;
}
}
.adf-form-field-input .adf-error-icon {
@include mixins.adf-error-icon;
}
.adf-container-widget .adf-multiline-text-widget .adf-form-field-input.adf-has-counter {
margin-bottom: 44px;
}
@@ -24,6 +24,7 @@ import { MultilineTextWidgetComponentComponent } from './multiline-text.widget';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnitTestingUtils } from '../../../../testing/unit-testing-utils';
import { ADF_CUSTOM_MESSAGE } from '../core/custom-validation-message.token';
import { ADF_TYPED_VALUE_FORMATTING_ENABLED } from '../../../services/form-field-value-formatter.token';
import { of, Subject } from 'rxjs';
describe('MultilineTextWidgetComponentComponent', () => {
@@ -329,4 +330,92 @@ describe('MultilineTextWidgetComponentComponent - ADF_CUSTOM_MESSAGE', () => {
expect(widget.field.validationSummary.message).toBe('FORM.FIELD.VALIDATOR.INVALID_VALUE');
});
});
describe('typed value formatting', () => {
describe('when flag is on', () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [MultilineTextWidgetComponentComponent],
providers: [{ provide: ADF_TYPED_VALUE_FORMATTING_ENABLED, useValue: true }]
});
fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent);
widget = fixture.componentInstance;
testingUtils = new UnitTestingUtils(fixture.debugElement);
});
it('should return formatted name for a People value in read-only mode', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alice', lastName: 'Brown' }],
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).toBe('Alice Brown');
});
it('should not return [object Object] for a complex field value', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alice', lastName: 'Brown' }],
readOnly: true
});
fixture.detectChanges();
expect(String(widget.displayValue)).not.toContain('[object Object]');
});
it('should pass through plain string values unchanged', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'multiline-id',
type: FormFieldTypes.MULTILINE_TEXT,
value: 'plain text',
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).toBe('plain text');
});
it('should not JSON-stringify a Date value for an unregistered type', () => {
const date = new Date('2026-06-02T14:30:00.000Z');
widget.field = new FormFieldModel(new FormModel(), {
id: 'date-id',
type: FormFieldTypes.MULTILINE_TEXT,
value: date,
readOnly: true
});
fixture.detectChanges();
expect(String(widget.displayValue)).toBe(String(date));
expect(String(widget.displayValue)).not.toContain('"');
});
});
describe('when flag is off', () => {
beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [MultilineTextWidgetComponentComponent]
});
fixture = TestBed.createComponent(MultilineTextWidgetComponentComponent);
widget = fixture.componentInstance;
});
it('should not format complex field values (default behaviour preserved)', () => {
widget.field = new FormFieldModel(new FormModel(), {
id: 'people-field',
type: FormFieldTypes.PEOPLE,
value: [{ firstName: 'Alice', lastName: 'Brown' }],
readOnly: true
});
fixture.detectChanges();
expect(widget.displayValue).not.toBe('Alice Brown');
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More