diff --git a/.editorconfig b/.editorconfig index 24f0393b..5e9ea124 100644 --- a/.editorconfig +++ b/.editorconfig @@ -311,7 +311,7 @@ csharp_space_between_empty_square_brackets = false csharp_space_between_square_brackets = false # Wrap options # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#wrap-options -csharp_preserve_single_line_statements = false +csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = false ########################################## diff --git a/.env.example b/.env.example index ec5db6b1..a63568f3 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,50 @@ TELEGRAM_ENABLED=false # TELEGRAM_BOT_TOKEN= # TELEGRAM_BOT_USERNAME= +# ═══════════════════════════════════════════════════════════════════════════════ +# EXTERNAL SSO / OIDC (optional — delegate login to your own OAuth2/OIDC provider) +# ═══════════════════════════════════════════════════════════════════════════════ +# Point PoracleWeb at any OAuth2/OIDC provider (Keycloak, Authentik, Auth0, Okta, …) for SSO. +# The provider's userinfo endpoint must return a claim holding the user's Poracle id +# (a Discord/Telegram id) — set OIDC_IDENTITY_CLAIM to that claim name. +# Enabled is auto-inferred when ClientId + the three URLs are all set; set explicitly to override. +# Replace the example URLs below with your provider's actual endpoints. +# OIDC_ENABLED=true +# OIDC_PROVIDER_NAME=My SSO +# OIDC_AUTHORIZATION_URL=https://sso.example.com/authorize +# OIDC_TOKEN_URL=https://sso.example.com/oauth/token +# OIDC_USERINFO_URL=https://sso.example.com/oauth/userinfo +# OIDC_CLIENT_ID=your_oidc_client_id +# OIDC_CLIENT_SECRET=your_oidc_client_secret +# OIDC_SCOPES=openid profile email +# OIDC_IDENTITY_CLAIM=discord_id +# OIDC_USERNAME_CLAIM=preferred_username +# OIDC_AVATAR_CLAIM=picture +# OIDC_IDENTITY_TYPE=discord:user +# OIDC_USE_PKCE=true +# +# --- Refresh tokens (optional, opt-in) — silent session renewal + revocation propagation --- +# When OFF (default) the provider's tokens are discarded after login and the internal session +# JWT lives its full Jwt:ExpirationMinutes (24h); users re-auth at expiry. When ON, PoracleWeb +# brokers the provider's refresh token SERVER-SIDE (encrypted at rest, never sent to the browser), +# silently renews the session, and propagates provider-side disable/logout. Requires the provider +# to actually issue a refresh token. Fully provider-agnostic — see docs/configuration/oidc-refresh-tokens.md. +# OIDC_USE_REFRESH_TOKENS=true +# OIDC_ACCESS_TOKEN_MINUTES=30 # internal JWT lifetime for refresh-backed OIDC sessions only +# OIDC_REFRESH_TOKEN_LIFETIME_DAYS=30 # PoracleWeb-side absolute session cap before a real re-login +# OIDC_SESSION_REVOKED_RETENTION_DAYS=2 # how long revoked/rotated session rows are kept (replay detection) before cleanup deletes them +# OIDC_OFFLINE_ACCESS_SCOPE=offline_access # appended to the authorize scope so the provider issues an RT; empty to disable +# OIDC_TOKEN_AUTH_METHOD=client_secret_post # client_secret_post (body) | client_secret_basic (HTTP Basic) +# +# Per-provider notes (token auth method / offline scope / identity claim): +# PogoAlerts : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=discord_id +# Keycloak : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub +# Authentik : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Auth0 : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Okta : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub +# Azure/Entra: OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Google : OIDC_OFFLINE_ACCESS_SCOPE= (empty) and append ?access_type=offline to OIDC_AUTHORIZATION_URL + # ═══════════════════════════════════════════════════════════════════════════════ # PORACLE API — your running PoracleNG instance # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index eabad42e..7c43efa2 100644 --- a/.github/workflows/auto-merge-deps.yml +++ b/.github/workflows/auto-merge-deps.yml @@ -1,11 +1,13 @@ name: Dependabot auto-merge +# Only triggers on pull_request_target. Listing `push:` here previously caused +# the workflow to fire on push events instead of pull_request_target ones, +# so Dependabot PRs never got auto-approved and every push recorded a failure +# run. pr-labeler.yml uses pull_request_target alone and triggers correctly, +# which was the side-by-side that confirmed the issue. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] - # Claim push events so GitHub doesn't create phantom 0-job failed runs. - # The job early-exits for non-pull_request_target events. - push: permissions: contents: write @@ -13,17 +15,19 @@ permissions: jobs: auto-merge: - # Skip the job entirely on push events so GitHub records the run as "skipped" - # (neutral, green in status UI) instead of "failure" with 0 jobs. The prior - # approach of claiming push with in-step gates still produced failed runs - # because the job itself never spawned for non-dependabot pushes. - if: github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: + # Sentinel step so the run records as "success" for non-Dependabot PRs. + # Without it, every step below is gated by `github.actor == 'dependabot[bot]'` + # and a non-Dependabot PR would produce a job with zero successful steps, + # which GitHub records as failure. + - name: Workflow ran + run: echo "Auto-merge workflow evaluated for actor=${{ github.actor }}" + - name: Fetch Dependabot metadata id: meta if: github.actor == 'dependabot[bot]' - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 - name: Enable auto-merge for low-risk bumps # Auto-merge criteria: @@ -49,7 +53,7 @@ jobs: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.dependency-group != '' ) - run: gh pr review --approve "$PR_URL" --body "Auto-approved: low-risk bump, gated on CI." + run: gh pr review --approve "$PR_URL" --body 'Auto-approved low-risk bump, gated on CI.' env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dc1bfde1..73bc2852 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,150 +1,72 @@ -name: Update Changelog +name: Changelog Check + +# Verify-only: confirms a PR adds an entry under "## [Unreleased]" in CHANGELOG.md. +# It never writes to the repo, so it cannot trip branch protection on `main`. +# Replaces the old post-merge auto-writer, which always failed pushing to protected main +# and risked duplicate entries. Release cuts are still handled by release-changelog.yml. on: pull_request: - types: [closed] + types: [opened, synchronize, reopened, labeled, unlabeled] branches: [main] +permissions: + contents: read + jobs: - update-changelog: - if: github.event.pull_request.merged == true + changelog: + name: Changelog entry present runs-on: ubuntu-latest - permissions: - contents: write - steps: - name: Checkout uses: actions/checkout@v6 with: - ref: main fetch-depth: 0 - - name: Categorize PR - id: categorize + - name: Require a CHANGELOG entry under [Unreleased] + env: + # Passed via env (not inlined) to avoid shell injection from PR titles/labels. + TITLE: ${{ github.event.pull_request.title }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - TITLE="${{ github.event.pull_request.title }}" - PR_NUM="${{ github.event.pull_request.number }}" - PR_URL="${{ github.event.pull_request.html_url }}" + set -euo pipefail - # Extract category from conventional commit prefix - if echo "$TITLE" | grep -qiE '^feat(\(.*\))?[!]?:'; then - CATEGORY="Added" - elif echo "$TITLE" | grep -qiE '^fix(\(.*\))?[!]?:'; then - CATEGORY="Fixed" - elif echo "$TITLE" | grep -qiE '^refactor(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^perf(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^breaking(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^deprecate(\(.*\))?[!]?:'; then - CATEGORY="Deprecated" - elif echo "$TITLE" | grep -qiE '^remove(\(.*\))?[!]?:'; then - CATEGORY="Removed" - elif echo "$TITLE" | grep -qiE '^security(\(.*\))?[!]?:'; then - CATEGORY="Security" - elif echo "$TITLE" | grep -qiE '^docs(\(.*\))?[!]?:'; then - echo "skip=true" >> "$GITHUB_OUTPUT" + # 1) Exempt non-user-facing PR types (mirrors the previous skip set). + if printf '%s' "$TITLE" | grep -qiE '^(docs|style|chore|ci|test|build)(\(.*\))?[!]?:'; then + echo "Exempt PR type — skipping changelog check." + echo " title: $TITLE" exit 0 - elif echo "$TITLE" | grep -qiE '^(style|chore|ci|test)(\(.*\))?[!]?:'; then - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - else - CATEGORY="Changed" fi - # Strip prefix from title for the entry text - ENTRY=$(echo "$TITLE" | sed -E 's/^[a-zA-Z]+(\(.*\))?[!]?:\s*//') - - echo "category=$CATEGORY" >> "$GITHUB_OUTPUT" - echo "entry=$ENTRY" >> "$GITHUB_OUTPUT" - echo "pr_num=$PR_NUM" >> "$GITHUB_OUTPUT" - echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - - - name: Update CHANGELOG.md - if: steps.categorize.outputs.skip != 'true' - run: | - CATEGORY="${{ steps.categorize.outputs.category }}" - ENTRY="${{ steps.categorize.outputs.entry }}" - PR_NUM="${{ steps.categorize.outputs.pr_num }}" - PR_URL="${{ steps.categorize.outputs.pr_url }}" - - # Skip if this PR is already referenced in the [Unreleased] section - # (e.g., changelog was updated manually in the PR branch) - UNRELEASED_BLOCK=$(awk '/^## \[Unreleased\]/,/^## \[[0-9]/' CHANGELOG.md 2>/dev/null) - if echo "$UNRELEASED_BLOCK" | grep -qF "#$PR_NUM"; then - echo "PR #$PR_NUM already referenced in [Unreleased] — skipping auto-insert" + # 2) Manual escape hatch for legitimate exceptions. + if printf ',%s,' "$LABELS" | grep -q ',skip-changelog,'; then + echo "skip-changelog label present — skipping changelog check." exit 0 fi - # Check if CHANGELOG.md exists - if [ ! -f CHANGELOG.md ]; then - cat > CHANGELOG.md << 'INIT' - # Changelog - - All notable changes to this project are documented in this file. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + # 3) Extract the [Unreleased] block from both sides of the PR. + unreleased() { + git show "$1:CHANGELOG.md" 2>/dev/null \ + | awk '/^## \[Unreleased\]/{f=1; next} f && /^## \[/{f=0} f' + } + base_block="$(unreleased "$BASE_SHA" || true)" + head_block="$(unreleased "$HEAD_SHA" || true)" - ## [Unreleased] - INIT - fi + # 4) Top-level entries this PR newly adds under [Unreleased]. + new_entries="$(comm -13 \ + <(printf '%s\n' "$base_block" | grep -E '^- ' | sort -u) \ + <(printf '%s\n' "$head_block" | grep -E '^- ' | sort -u) || true)" - # Use awk to insert entry under [Unreleased] only (not older release sections) - # Pass values via environment to avoid awk -v escaping issues with special characters - export AWK_CATEGORY="### $CATEGORY" - export AWK_ENTRY="- $ENTRY ([PR #$PR_NUM]($PR_URL))" - awk ' - BEGIN { category=ENVIRON["AWK_CATEGORY"]; entry=ENVIRON["AWK_ENTRY"]; found_unreleased=0; inserted=0 } - /^## \[Unreleased\]/ { found_unreleased=1; print; next } - # If we hit the next version section, unreleased block is over - found_unreleased && /^## \[/ { - if (!inserted) { - print "" - print category - print entry - inserted=1 - } - found_unreleased=0 - print; next - } - # Found existing category header under [Unreleased] - found_unreleased && !inserted && $0 == category { - print - print entry - inserted=1 - next - } - # Hit a different category or blank line before any matching category — insert new section before it - found_unreleased && !inserted && /^### / { - print category - print entry - print "" - inserted=1 - print; next - } - { print } - END { - if (!inserted) { - print "" - print category - print entry - } - } - ' CHANGELOG.md > CHANGELOG.tmp - if [ -s CHANGELOG.tmp ]; then - mv CHANGELOG.tmp CHANGELOG.md - else - echo "::error::awk produced empty output — CHANGELOG.md not modified" - rm -f CHANGELOG.tmp - exit 1 + if [ -n "$new_entries" ]; then + echo "Found new [Unreleased] entry/entries:" + printf '%s\n' "$new_entries" + exit 0 fi - - name: Commit and push - if: steps.categorize.outputs.skip != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git diff --cached --quiet || (git commit -m "docs: update changelog for PR #${{ github.event.pull_request.number }}" && git push) + echo "::error::This PR has no new entry under '## [Unreleased]' in CHANGELOG.md." + echo "Add a Keep a Changelog entry (e.g. under '### Fixed'), or:" + echo " - use a 'docs|style|chore|ci|test|build:' PR title for non-user-facing changes, or" + echo " - apply the 'skip-changelog' label for a legitimate exception." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8a54e48..bd53fbbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: dotnet-version: '10.0.x' - name: Cache NuGet packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.slnx') }} @@ -39,7 +39,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: backend-test-results path: '**/TestResults/*.trx' @@ -57,12 +57,19 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js 22 - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' cache-dependency-path: Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json + # Node 22 ships npm 10.9.7, whose `npm ci` rejects lockfiles that omit nested + # optional-peer entries (chokidar@4 / readdirp@4 under @angular-devkit/*) that + # newer npm versions prune. Pinning npm 11 here matches what Dependabot uses + # to regenerate lockfiles, so `npm ci` stays in sync with that resolution. + - name: Pin npm 11 + run: npm install -g npm@11 + - name: Install dependencies run: npm ci diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 9422e5a1..8d6d82f3 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -46,7 +46,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true @@ -56,7 +56,7 @@ jobs: cache-to: type=gha,mode=max - name: Comment preview instructions on PR - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@v3 with: header: preview-image message: | diff --git a/.github/workflows/docker-prune.yml b/.github/workflows/docker-prune.yml index c706a3c2..bfe5e2c0 100644 --- a/.github/workflows/docker-prune.yml +++ b/.github/workflows/docker-prune.yml @@ -18,7 +18,7 @@ jobs: pull-requests: read steps: - name: Delete pr-* tags for closed PRs - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: ORG: ${{ env.ORG }} PACKAGE: ${{ env.PACKAGE }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 15f2ab82..d90d5c3b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: type=sha,prefix=,enable=${{ github.event_name == 'release' }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c178649..c4860e2c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,12 @@ on: paths: - 'docs/**' - 'mkdocs.yml' + # Rebuild when a release is published so the mkdocs-material version badge + # (fetched at build time from the GitHub Releases API) stays current even + # when a release touches only code/CHANGELOG and not docs/. + release: + types: [published] + workflow_dispatch: permissions: contents: write @@ -16,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 2479019f..fa27ebc7 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Apply label from branch prefix or title - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index 5a2b9019..594bcf12 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -27,15 +27,47 @@ jobs: # Replace [Unreleased] with the version and add new [Unreleased] sed -i "s/^## \[Unreleased\]/## [Unreleased]\n\n## [$VERSION] - $DATE/" CHANGELOG.md - # Update comparison links - PREV_VERSION=$(grep -oP '^\[[\d.]+\]' CHANGELOG.md | head -2 | tail -1 | tr -d '[]') + # Update comparison links. The new version's [x.y.z]: link is not added until the sed + # below, so the first existing version link-def is the immediately-preceding release. + PREV_VERSION=$(grep -oP '^\[[\d.]+\]' CHANGELOG.md | head -1 | tr -d '[]') if [ -n "$PREV_VERSION" ]; then sed -i "s|\[Unreleased\]: .*|[Unreleased]: https://github.com/${{ github.repository }}/compare/v$VERSION...HEAD\n[$VERSION]: https://github.com/${{ github.repository }}/compare/v$PREV_VERSION...v$VERSION|" CHANGELOG.md fi + # Flags whether the GitHub App is configured. Secrets can't be read in `if:` conditions + # directly, so surface it as a step output the later steps can gate on. + - name: Detect app config + id: cfg + env: + APP_ID: ${{ secrets.CHANGELOG_APP_ID }} + run: | + if [ -n "$APP_ID" ]; then + echo "has_app=true" >> "$GITHUB_OUTPUT" + else + echo "has_app=false" >> "$GITHUB_OUTPUT" + echo "::warning::CHANGELOG_APP_ID/CHANGELOG_APP_PRIVATE_KEY not set — opening the changelog PR with GITHUB_TOKEN, which does not trigger the required CI checks. The PR will need a manual (admin) merge. See the workflow header for setup." + fi + + # An App installation token makes the PR author the app bot, so push/PR events trigger the + # required status checks (Backend/Frontend/Changelog). GITHUB_TOKEN-authored PRs do NOT + # trigger workflows (GitHub's recursion guard), which leaves required checks permanently + # pending and the PR blocked. App tokens are short-lived and auto-minted per run — no PAT + # rotation. Requires a GitHub App (Contents: write, Pull requests: write) installed on this + # repo, with its App ID and a private key stored as the secrets below. + - name: Generate GitHub App token + id: app-token + if: steps.cfg.outputs.has_app == 'true' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.CHANGELOG_APP_ID }} + private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} + - name: Open PR with changelog update - uses: peter-evans/create-pull-request@v7 + id: cpr + uses: peter-evans/create-pull-request@v8 with: + # App token when configured; otherwise GITHUB_TOKEN (PR opens but needs a manual merge). + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} commit-message: "docs: cut changelog for ${{ github.event.release.tag_name }}" title: "docs: cut changelog for ${{ github.event.release.tag_name }}" body: | @@ -48,3 +80,23 @@ jobs: base: main labels: docs,automated delete-branch: true + + # Approve from the github-actions[bot] identity. The PR was authored by the app bot, so this + # is a distinct identity and satisfies the "1 approval" branch-protection rule. + - name: Approve the changelog PR + if: steps.cfg.outputs.has_app == 'true' && steps.cpr.outputs.pull-request-number + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.cpr.outputs.pull-request-number }} + run: | + gh pr review "$PR" --approve \ + --body "Automated changelog cut — approving the mechanical [Unreleased] → version promotion. Source entries were already reviewed on their own PRs." + + # Squash-merge once the required checks pass (they run because the PR is app-authored). + - name: Enable auto-merge + if: steps.cfg.outputs.has_app == 'true' && steps.cpr.outputs.pull-request-number + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR: ${{ steps.cpr.outputs.pull-request-number }} + run: | + gh pr merge "$PR" --squash --auto --delete-branch diff --git a/.gitignore b/.gitignore index 9ce5b7db..1db3376b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,20 @@ avatar-cache.json *.http *.mjs screenshot-*.png +# Playwright MCP server output + loose screenshots at the repo root +# (tracked PNGs all live under ClientApp/public/assets, so this is root-only) +.playwright-mcp/ +/*.png Applications/Pgan.PoracleWebNet.Api/cookies.txt beta-discord-messages.txt # ASP.NET DataProtection runtime keys — never commit Data/dataprotection-keys/ +# DATA_DIR fallback for standalone `dotnet run` (Program.cs uses ./data when DATA_DIR is unset) +Applications/Pgan.PoracleWebNet.Api/data/ + +# Built Angular bundle copied into the API host on publish — regenerated, never committed +Applications/Pgan.PoracleWebNet.Api/wwwroot/ + +# Local-only Docker test build (npm 11 pin); not part of the app build +Dockerfile.local diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs index 575a3a60..62c35012 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs @@ -15,6 +15,13 @@ public interface IJwtService /// string GenerateToken(UserInfo user); + /// + /// Generates a fresh JWT with an explicit lifetime (minutes), overriding the configured + /// default. Used for refresh-backed OIDC sessions, which are deliberately short-lived so + /// provider-side revocation propagates quickly via silent refresh. + /// + string GenerateToken(UserInfo user, int lifetimeMinutes); + /// /// Generates a JWT for an impersonated user. Includes an impersonatedBy claim /// identifying the admin who initiated the impersonation. diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs index 87f9a55c..88e9d401 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs @@ -33,6 +33,12 @@ public string GenerateToken(UserInfo user) return this.WriteToken(claims); } + public string GenerateToken(UserInfo user, int lifetimeMinutes) + { + var claims = BuildClaims(user); + return this.WriteToken(claims, lifetimeMinutes); + } + public string GenerateImpersonationToken(UserInfo user, string impersonatedBy) { var claims = BuildClaims(user); @@ -88,7 +94,9 @@ private static List BuildClaims(UserInfo user) return claims; } - private string WriteToken(List claims) + private string WriteToken(List claims) => this.WriteToken(claims, this._settings.ExpirationMinutes); + + private string WriteToken(List claims, int lifetimeMinutes) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this._settings.Secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -97,7 +105,7 @@ private string WriteToken(List claims) issuer: this._settings.Issuer, audience: this._settings.Audience, claims: claims, - expires: DateTime.UtcNow.AddMinutes(this._settings.ExpirationMinutes), + expires: DateTime.UtcNow.AddMinutes(lifetimeMinutes), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs new file mode 100644 index 00000000..192965f6 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs @@ -0,0 +1,115 @@ +namespace Pgan.PoracleWebNet.Api.Configuration; + +/// +/// Configuration for a generic external OIDC / OAuth2 login provider. This lets any +/// self-hoster delegate PoracleWeb login to their own identity provider (PGAN's +/// PogoAlerts being one instance). It mirrors the Discord flow, parameterized by config. +/// All values come from env/appsettings (the provider secret is never stored in the DB); +/// the admin runtime on/off toggle is the separate enable_oidc site setting. +/// +public class OidcSettings +{ + /// Master switch from server config. When false the provider is hidden regardless of other values. + public bool Enabled { get; set; } + + /// Display name shown on the login button, e.g. "PogoAlerts". + public string ProviderName { get; set; } = string.Empty; + + /// Browser-facing authorization endpoint. For PogoAlerts this is e.g. https://pogoalerts.net/login. + public string AuthorizationUrl { get; set; } = string.Empty; + + /// Token endpoint that exchanges the authorization code for an access token. + public string TokenUrl { get; set; } = string.Empty; + + /// + /// Optional OIDC RP-initiated logout (end-session) endpoint. When set, signing out of + /// PoracleWeb redirects the browser here with a post_logout_redirect_uri so the + /// provider can also end its own session (true single logout). When empty, logout is + /// local-only (the provider session survives). For PogoAlerts this is e.g. + /// https://pogoalerts.net/logout. + /// + public string EndSessionUrl { get; set; } = string.Empty; + + /// UserInfo endpoint (OpenID Connect compatible) returning the user's claims. + public string UserInfoUrl { get; set; } = string.Empty; + + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + + /// Space-delimited OAuth scopes requested at authorization time. + public string Scopes { get; set; } = "openid profile email"; + + /// + /// UserInfo claim whose value is the Poracle human id (a Discord or Telegram id). + /// Defaults to discord_id (PogoAlerts passes through the linked Discord id); + /// falls back to sub when the configured claim is absent. + /// + public string IdentityClaim { get; set; } = "discord_id"; + + /// UserInfo claim used as the display username. + public string UsernameClaim { get; set; } = "preferred_username"; + + /// UserInfo claim used as the avatar URL. + public string AvatarClaim { get; set; } = "picture"; + + /// + /// Value written to the JWT type claim for users who log in via this provider. + /// Defaults to discord:user so downstream admin/role resolution treats the + /// passed-through Discord id consistently with a direct Discord login. + /// + public string IdentityType { get; set; } = "discord:user"; + + /// Whether to use PKCE (Proof Key for Code Exchange) — recommended and supported by PogoAlerts. + public bool UsePkce { get; set; } = true; + + /// + /// Master opt-in for consuming the provider's refresh token (silent session renewal + + /// revocation propagation). Default false — when off, behavior is identical to a + /// plain login: the provider's tokens are discarded and the internal JWT lives its full + /// . Requires the provider to actually issue a + /// refresh token (standard providers gate that behind the offline_access scope — + /// see ). + /// + public bool UseRefreshTokens { get; set; } + + /// + /// Internal JWT lifetime (minutes) for refresh-backed OIDC sessions only. Kept short so a + /// disable/revocation at the provider propagates within roughly one access-token lifetime. + /// Other logins (Discord, Telegram, local, OIDC without refresh) are unaffected and keep + /// . + /// + public int AccessTokenMinutes { get; set; } = 30; + + /// + /// PoracleWeb-side absolute cap (days) on a refresh session/family before a real re-login is + /// forced. Independent of the provider's own refresh-token lifetime; if the provider's token + /// expires first, the refresh call fails and the session is revoked — correct either way. + /// + public int RefreshTokenLifetimeDays { get; set; } = 30; + + /// + /// How long (days) a revoked/rotated oidc_sessions row is retained before the cleanup + /// service deletes it. Revoked rows are kept briefly so a replayed old opaque token is still + /// detected (and family-revoked) rather than silently 401ing; replay happens fast, so a short + /// window suffices. Kept separate from so frequent + /// rotation doesn't pile up 30 days of dead rows. Expired rows are deleted regardless of this. + /// + public int RevokedRetentionDays { get; set; } = 2; + + /// + /// Scope appended to the authorization request (only when is on + /// and it isn't already present) so a standards-compliant provider issues a refresh token. + /// Defaults to offline_access. Set empty for providers that issue refresh tokens + /// unconditionally, or that use a non-standard mechanism (e.g. Google's + /// access_type=offline appended directly to ). + /// + public string OfflineAccessScope { get; set; } = "offline_access"; + + /// + /// How client credentials are presented at the token endpoint: client_secret_post + /// (default — credentials in the form body, what PogoAlerts uses) or client_secret_basic + /// (HTTP Basic auth header — the default for Keycloak/Okta). Applies to both the + /// authorization-code exchange and the refresh-token grant. + /// + public string TokenEndpointAuthMethod { get; set; } = "client_secret_post"; +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index be23019d..551a4d27 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register Services services.AddScoped(); @@ -77,10 +78,12 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -121,6 +124,9 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv // Register HttpClient for PoracleNG human/profile proxy (replaces direct DB writes) services.AddHttpClient(); + // Register HttpClient for PoracleNG summary schedule proxy (quest summary delivery) + services.AddHttpClient(); + // Register HttpClient for Discord notification service services.AddHttpClient(client => { @@ -144,6 +150,11 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv } }); + // Register the generic OIDC HTTP client (code exchange / refresh / userinfo) and the + // server-side refresh-session service (opaque-token rotation + encrypted RT storage). + services.AddHttpClient(); + services.AddScoped(); + // Register JWT service (shared token generation across controllers) services.AddSingleton(); @@ -151,6 +162,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.Configure(configuration.GetSection("Jwt")); services.Configure(configuration.GetSection("Discord")); services.Configure(configuration.GetSection("Telegram")); + services.Configure(configuration.GetSection("Oidc")); services.Configure(configuration.GetSection("Poracle")); services.Configure(configuration.GetSection("Koji")); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs index aaebf311..793fb216 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs @@ -46,6 +46,7 @@ public async Task GetAllUsers() h.DisabledDate, h.CurrentProfileNo, h.Language, + h.Notes, AvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(h.Id, h.Type) }); @@ -79,6 +80,7 @@ public async Task GetUser([FromQuery] string id) human.Area, human.Latitude, human.Longitude, + human.Notes, AvatarUrl = avatarUrl }); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs index f9878df1..b5ec92ce 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs @@ -77,7 +77,8 @@ public async Task ApproveSubmission(int id, [FromBody] ApproveReq try { - var result = await this._userGeofenceService.ApproveSubmissionAsync(this.UserId, id, request?.PromotedName); + var result = await this._userGeofenceService.ApproveSubmissionAsync( + this.UserId, id, request?.PromotedName, request?.ParentId, request?.GroupName); return this.Ok(result); } catch (InvalidOperationException ex) @@ -119,6 +120,18 @@ public string? PromotedName { get; set; } + + /// Optional Koji parent id to assign on promotion. Null keeps the submission's existing region. + public int? ParentId + { + get; set; + } + + /// Optional Koji group/region display name to assign on promotion. Null keeps the existing value. + public string? GroupName + { + get; set; + } } public class RejectRequest diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs index 2e6353a6..d84f1a21 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services.Oidc; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -21,14 +22,19 @@ public partial class AuthController( ISiteSettingService siteSettingService, IWebhookDelegateService webhookDelegateService, IJwtService jwtService, + IOidcClient oidcClient, + IOidcSessionService oidcSessionService, IOptions discordSettings, IOptions telegramSettings, + IOptions oidcSettings, IOptions poracleSettings, IConfiguration configuration, ILogger logger) : BaseApiController { private const string EnableDiscordKey = "enable_discord"; private const string EnableTelegramKey = "enable_telegram"; + private const string EnableOidcKey = "enable_oidc"; + private const string EnableOidcSloKey = "enable_oidc_slo"; private readonly IHumanService _humanService = humanService; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; @@ -36,8 +42,11 @@ public partial class AuthController( private readonly ISiteSettingService _siteSettingService = siteSettingService; private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; private readonly IJwtService _jwtService = jwtService; + private readonly IOidcClient _oidcClient = oidcClient; + private readonly IOidcSessionService _oidcSessionService = oidcSessionService; private readonly DiscordSettings _discordSettings = discordSettings.Value; private readonly TelegramSettings _telegramSettings = telegramSettings.Value; + private readonly OidcSettings _oidcSettings = oidcSettings.Value; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly string[] _allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; private readonly ILogger _logger = logger; @@ -212,6 +221,281 @@ public async Task DiscordCallback([FromQuery] string code, [FromQ return this.Redirect($"{frontendUrl}/auth/discord/callback#token={jwt}"); } + [AllowAnonymous] + [HttpGet("oidc/login")] + public IActionResult OidcLogin() + { + // Generic external OIDC/OAuth2 provider — a configurable twin of the Discord flow. + // No early enable_oidc gate here: admins must be able to log in even when the + // provider is disabled for regular users. The check runs in OidcCallback() once + // we know whether the user is an admin (mirrors Discord). + if (!this.OidcConfigured()) + { + return this.NotFound(new + { + error = "External login provider is not configured." + }); + } + + var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var isHttps = string.Equals(this.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase); + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Secure = isHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromMinutes(10) + }; + + this.Response.Cookies.Append("oauth_state", state, cookieOptions); + + // Save the frontend origin (validated against CORS origins) so the callback knows + // where to redirect — identical handling to DiscordLogin. + var selfOrigin = $"{this.Request.Scheme}://{this.Request.Host}"; + var origin = selfOrigin; + + var referer = this.Request.Headers.Referer.FirstOrDefault(); + if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refererUri)) + { + var refererOrigin = $"{refererUri.Scheme}://{refererUri.Authority}"; + if (this._allowedOrigins.Length > 0 + ? this._allowedOrigins.Any(o => string.Equals(o, refererOrigin, StringComparison.OrdinalIgnoreCase)) + : string.Equals(refererOrigin, selfOrigin, StringComparison.OrdinalIgnoreCase)) + { + origin = refererOrigin; + } + } + + this.Response.Cookies.Append("oauth_origin", origin, cookieOptions); + + var callbackUri = $"{this.Request.Scheme}://{this.Request.Host}/api/auth/oidc/callback"; + + var query = new Dictionary + { + ["client_id"] = this._oidcSettings.ClientId, + ["redirect_uri"] = callbackUri, + ["response_type"] = "code", + ["scope"] = this.BuildOidcScope(), + ["state"] = state, + }; + + if (this._oidcSettings.UsePkce) + { + // PKCE: store the verifier in an HttpOnly cookie and send only the S256 challenge. + var codeVerifier = Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + var challenge = Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier))); + this.Response.Cookies.Append("oauth_pkce_verifier", codeVerifier, cookieOptions); + query["code_challenge"] = challenge; + query["code_challenge_method"] = "S256"; + } + + return this.Redirect(BuildUrlWithQuery(this._oidcSettings.AuthorizationUrl, query)); + } + + [AllowAnonymous] + [HttpGet("oidc/logout")] + public async Task OidcLogout() + { + // OIDC RP-initiated (single) logout: bounce the browser to the provider's end-session + // endpoint so it can clear its OWN session too, then return to the signed-out landing. + // The frontend has already discarded the local JWT before calling this. + var selfOrigin = $"{this.Request.Scheme}://{this.Request.Host}"; + var origin = selfOrigin; + + // Validate the return origin the same way the login flow validates oauth_origin. + var referer = this.Request.Headers.Referer.FirstOrDefault(); + if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refererUri)) + { + var refererOrigin = $"{refererUri.Scheme}://{refererUri.Authority}"; + if (this._allowedOrigins.Length > 0 + ? this._allowedOrigins.Any(o => string.Equals(o, refererOrigin, StringComparison.OrdinalIgnoreCase)) + : string.Equals(refererOrigin, selfOrigin, StringComparison.OrdinalIgnoreCase)) + { + origin = refererOrigin; + } + } + + // ?loggedout=1 tells the login page to show the signed-out panel instead of auto-redirecting. + var postLogout = $"{origin}/login?loggedout=1"; + + // Single logout requires both a configured end-session endpoint and the admin + // runtime toggle (enable_oidc_slo; absent = on). Otherwise fall back to local logout. + var sloSetting = await this._siteSettingService.GetValueAsync(EnableOidcSloKey); + var sloDisabledByAdmin = string.Equals(sloSetting, "false", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(this._oidcSettings.EndSessionUrl) || sloDisabledByAdmin) + { + return this.Redirect(postLogout); + } + + var query = new Dictionary + { + ["post_logout_redirect_uri"] = postLogout, + ["client_id"] = this._oidcSettings.ClientId, + }; + + return this.Redirect(BuildUrlWithQuery(this._oidcSettings.EndSessionUrl, query)); + } + + [AllowAnonymous] + [HttpGet("oidc/callback")] + public async Task OidcCallback([FromQuery] string code, [FromQuery] string? state) + { + var frontendUrl = this.GetFrontendUrl(); + + // Validate OAuth state parameter for CSRF protection + var savedState = this.Request.Cookies["oauth_state"]; + this.Response.Cookies.Delete("oauth_state"); + + var pkceVerifier = this.Request.Cookies["oauth_pkce_verifier"]; + this.Response.Cookies.Delete("oauth_pkce_verifier"); + + if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(savedState) || state != savedState) + { + return this.BadRequest(new + { + error = "Invalid OAuth state. Possible CSRF attack." + }); + } + + if (!this.OidcConfigured()) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_disabled"); + } + + if (string.IsNullOrEmpty(code)) + { + return this.Redirect($"{frontendUrl}/login#error=missing_code"); + } + + // Exchange the authorization code for tokens via the generic, provider-agnostic OIDC client. + var redirectUri = $"{this.Request.Scheme}://{this.Request.Host}/api/auth/oidc/callback"; + var tokenResult = await this._oidcClient.ExchangeCodeAsync(code, redirectUri, pkceVerifier); + if (tokenResult is null) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_token_exchange_failed"); + } + + var userInfoJson = await this._oidcClient.GetUserInfoAsync(tokenResult.AccessToken); + if (userInfoJson is null) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_userinfo_failed"); + } + + var (userInfo, error) = await this.BuildOidcUserInfoAsync(userInfoJson.Value); + if (error is not null || userInfo is null) + { + return this.Redirect($"{frontendUrl}/login#error={error ?? "oidc_no_identity"}"); + } + + if (!string.IsNullOrEmpty(userInfo.AvatarUrl)) + { + Services.AvatarCacheService.SetAvatar(userInfo.Id, userInfo.AvatarUrl); + Services.AvatarCacheService.Save(); + } + + // When refresh-token consumption is enabled AND the provider actually issued a refresh + // token, persist an encrypted server-side session and hand the browser a short-lived JWT + // plus an opaque refresh token. Otherwise fall back to a normal full-lifetime JWT — this is + // the graceful path for providers that don't issue refresh tokens (or when the feature is off). + if (this._oidcSettings.UseRefreshTokens && !string.IsNullOrEmpty(tokenResult.RefreshToken)) + { + var (ip, ua) = this.GetClientMetadata(); + var opaque = await this._oidcSessionService.IssueAsync(userInfo.Id, tokenResult.RefreshToken, ip, ua); + var shortJwt = this._jwtService.GenerateToken(userInfo, this._oidcSettings.AccessTokenMinutes); + return this.Redirect($"{frontendUrl}/auth/oidc/callback#token={shortJwt}&refresh_token={opaque}"); + } + + if (this._oidcSettings.UseRefreshTokens) + { + LogOidcRefreshUnavailable(this._logger); + } + + var jwt = this._jwtService.GenerateToken(userInfo); + return this.Redirect($"{frontendUrl}/auth/oidc/callback#token={jwt}"); + } + + [AllowAnonymous] + [EnableRateLimiting("auth")] + [HttpPost("oidc/refresh")] + public async Task OidcRefresh([FromBody] OidcRefreshRequest request) + { + if (!this._oidcSettings.UseRefreshTokens || string.IsNullOrEmpty(request?.RefreshToken)) + { + return this.Unauthorized(new { error = "invalid_grant" }); + } + + var (ip, ua) = this.GetClientMetadata(); + + OidcRotationTicket ticket; + try + { + ticket = await this._oidcSessionService.StartRotationAsync(request.RefreshToken, ip, ua); + } + catch (UnauthorizedAccessException) + { + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Redeem the provider refresh token. A failure here means the provider revoked it (or the + // user was disabled at the provider) — propagate by revoking our family and logging out. + var tokenResult = await this._oidcClient.RefreshAsync(ticket.DecryptedRefreshToken); + if (tokenResult is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "provider_revoked"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Re-validate the user live (existence, enabled, roles) on every refresh. + var userInfoJson = await this._oidcClient.GetUserInfoAsync(tokenResult.AccessToken); + if (userInfoJson is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "provider_revoked"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + var (userInfo, error) = await this.BuildOidcUserInfoAsync(userInfoJson.Value); + if (error is not null || userInfo is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "account_inactive"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Propagate an admin disable: kill the session rather than keep renewing it. (A user who + // merely toggled their own alerts off keeps AdminDisable == false and is unaffected.) + if (userInfo.AdminDisable) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "admin_disable"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Non-rotating providers return no new refresh token — carry the existing one forward. + var newIdpRefreshToken = tokenResult.RefreshToken ?? ticket.DecryptedRefreshToken; + await this._oidcSessionService.CompleteRotationAsync(ticket, newIdpRefreshToken); + + var jwt = this._jwtService.GenerateToken(userInfo, this._oidcSettings.AccessTokenMinutes); + + return this.Ok(new + { + token = jwt, + refreshToken = ticket.NewOpaqueToken, + expiresIn = this._oidcSettings.AccessTokenMinutes * 60, + }); + } + + [AllowAnonymous] + [EnableRateLimiting("auth")] + [HttpPost("oidc/refresh/revoke")] + public async Task OidcRefreshRevoke([FromBody] OidcRefreshRequest request) + { + if (!string.IsNullOrEmpty(request?.RefreshToken)) + { + await this._oidcSessionService.RevokeAsync(request.RefreshToken, "logout"); + } + + return this.NoContent(); + } + [AllowAnonymous] [HttpPost("telegram/verify")] public async Task TelegramVerify([FromBody] Dictionary telegramData) @@ -376,6 +660,30 @@ public async Task Providers() var telegramSetting = await this._siteSettingService.GetValueAsync(EnableTelegramKey); var telegramDisabledByAdmin = string.Equals(telegramSetting, "false", StringComparison.OrdinalIgnoreCase); + // Generic external OIDC provider — "configured" requires the full server-side config. + // Unlike Discord/Telegram (absent setting = enabled), OIDC is OPT-IN: it is only + // active when enable_oidc is explicitly "true", so the default sign-in mode is local. + // The AUTH_FORCE_LOCAL break-glass env flag forces it off regardless — recovery when + // an admin switches to OIDC against a broken provider and locks everyone out. + var oidcConfigured = this.OidcConfigured(); + var oidcSetting = await this._siteSettingService.GetValueAsync(EnableOidcKey); + var forceLocal = configuration.GetValue("Auth:ForceLocal"); + var oidcEnabledByAdmin = string.Equals(oidcSetting, "true", StringComparison.OrdinalIgnoreCase) && !forceLocal; + + // Single logout: available when a provider end-session endpoint is configured AND the + // admin runtime toggle is on (enable_oidc_slo; absent = on once the URL is wired). + var endSessionConfigured = !string.IsNullOrWhiteSpace(this._oidcSettings.EndSessionUrl); + var sloSetting = await this._siteSettingService.GetValueAsync(EnableOidcSloKey); + var sloEnabledByAdmin = !string.Equals(sloSetting, "false", StringComparison.OrdinalIgnoreCase); + + // Silent refresh is active when the provider is configured and the server-side master + // switch (OIDC_USE_REFRESH_TOKENS) is on. Read-only status — there is intentionally no + // runtime admin override: enabling/disabling refresh is a deploy-time decision because it + // is coupled to the per-login JWT lifetime (turning it off mid-session would strand the + // short-lived JWTs of already-logged-in users). Single logout (above) is different — it + // only affects the next logout, so it stays a runtime toggle. + var refreshConfigured = oidcConfigured && this._oidcSettings.UseRefreshTokens; + return this.Ok(new { discord = new @@ -389,6 +697,17 @@ public async Task Providers() enabledByAdmin = !telegramDisabledByAdmin, botUsername = telegramConfigured ? this._telegramSettings.BotUsername : string.Empty, }, + oidc = new + { + configured = oidcConfigured, + enabledByAdmin = oidcEnabledByAdmin, + providerName = oidcConfigured ? this._oidcSettings.ProviderName : string.Empty, + // Whether single logout ("Sign out everywhere") is available: end-session + // endpoint configured AND enabled by the admin (enable_oidc_slo). + endSession = oidcConfigured && endSessionConfigured && sloEnabledByAdmin, + // Whether the frontend should run silent refresh (server brokers the provider RT). + refresh = refreshConfigured, + }, }); } @@ -672,6 +991,148 @@ private string GetFrontendUrl() return $"{this.Request.Scheme}://{this.Request.Host}"; } + /// + /// The external OIDC provider is "configured" only when enabled and the full set of + /// endpoints plus a client id is present. Mirrors the Discord "configured" check. + /// + private bool OidcConfigured() => + this._oidcSettings.Enabled + && !string.IsNullOrWhiteSpace(this._oidcSettings.AuthorizationUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.TokenUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.UserInfoUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.ClientId); + + /// + /// Builds the authorization-request scope, appending the configured offline-access scope + /// (default offline_access) when refresh consumption is enabled and it isn't already + /// present — the one and only scope mutation we perform. Standards-compliant providers gate + /// refresh-token issuance behind this scope; providers that issue unconditionally (or use a + /// non-standard mechanism) leave OIDC_OFFLINE_ACCESS_SCOPE empty. + /// + private string BuildOidcScope() + { + var scope = string.IsNullOrWhiteSpace(this._oidcSettings.Scopes) ? "openid" : this._oidcSettings.Scopes; + + if (this._oidcSettings.UseRefreshTokens && !string.IsNullOrWhiteSpace(this._oidcSettings.OfflineAccessScope)) + { + var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!parts.Contains(this._oidcSettings.OfflineAccessScope, StringComparer.Ordinal)) + { + scope = $"{scope} {this._oidcSettings.OfflineAccessScope}"; + } + } + + return scope; + } + + /// + /// Maps OIDC userinfo claims to a , re-validating identity, registration, + /// the enable_oidc admin gate, and role access. Returns (user, null) on success or + /// (null, errorCode) on any failure. Shared by the login callback and the refresh path so + /// a user disabled/derole'd at the provider is re-evaluated on every silent refresh. + /// + private async Task<(UserInfo? user, string? error)> BuildOidcUserInfoAsync(JsonElement userInfoJson) + { + // The identity claim maps to the Poracle human id (a Discord/Telegram id). + // Fall back to the standard OIDC `sub` claim when the configured claim is absent. + var identity = GetClaimString(userInfoJson, this._oidcSettings.IdentityClaim) + ?? GetClaimString(userInfoJson, "sub"); + if (string.IsNullOrEmpty(identity)) + { + return (null, "oidc_no_identity"); + } + + var username = GetClaimString(userInfoJson, this._oidcSettings.UsernameClaim) ?? identity; + var avatarUrl = GetClaimString(userInfoJson, this._oidcSettings.AvatarClaim); + + var human = await this._humanService.GetByIdAsync(identity); + if (human == null) + { + return (null, "user_not_registered"); + } + + var (isAdmin, managedWebhooks) = await this.GetRolesAsync(identity); + if (!isAdmin) + { + // Enforce enable_oidc site setting for non-admin users. + // Admins can always log in so they can re-enable the setting. + var oidcSetting = await this._siteSettingService.GetValueAsync(EnableOidcKey); + if (string.Equals(oidcSetting, "false", StringComparison.OrdinalIgnoreCase)) + { + LogAuthMethodDisabled(this._logger, "OIDC"); + return (null, "oidc_disabled"); + } + + // Reuse Discord guild role gating when the identity is a Discord id. + var roleCheckResult = await this.CheckRoleAccessAsync(identity); + if (roleCheckResult != null) + { + return (null, roleCheckResult); + } + } + + var userInfo = new UserInfo + { + Id = identity, + Username = username, + Type = string.IsNullOrEmpty(this._oidcSettings.IdentityType) ? "discord:user" : this._oidcSettings.IdentityType, + IsAdmin = isAdmin, + AdminDisable = human.AdminDisable == 1, + Enabled = human.Enabled == 1 && human.AdminDisable == 0, + ProfileNo = human.CurrentProfileNo, + AvatarUrl = avatarUrl, + ManagedWebhooks = managedWebhooks + }; + + return (userInfo, null); + } + + /// Best-effort client IP + user-agent for session audit metadata (not security-bearing). + private (string? ip, string? ua) GetClientMetadata() + { + var ip = this.HttpContext.Connection.RemoteIpAddress?.ToString(); + var ua = this.Request.Headers.UserAgent.ToString(); + return (ip, string.IsNullOrEmpty(ua) ? null : ua); + } + + /// + /// Reads a UserInfo claim as a string, tolerating both string and numeric JSON values + /// (e.g. a numeric sub). Returns null when absent or empty. + /// + private static string? GetClaimString(JsonElement userInfo, string claim) + { + if (string.IsNullOrEmpty(claim) || !userInfo.TryGetProperty(claim, out var prop)) + { + return null; + } + + var value = prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.GetRawText(), + _ => null + }; + + return string.IsNullOrEmpty(value) ? null : value; + } + + /// Base64url encoding without padding (RFC 7636), for PKCE verifier/challenge. + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// + /// Appends query parameters to a base URL, preserving any existing query string the + /// admin-configured authorization endpoint may already carry. Values are URL-encoded. + /// + private static string BuildUrlWithQuery(string baseUrl, IDictionary parameters) + { + var present = parameters + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, present); + } + [LoggerMessage(Level = LogLevel.Warning, Message = "Role-based access enabled but Discord BotToken or GuildId not configured.")] private static partial void LogRoleMisconfigured(ILogger logger); @@ -690,6 +1151,9 @@ private string GetFrontendUrl() [LoggerMessage(Level = LogLevel.Warning, Message = "Discord token exchange failed: {Status} {Body}")] private static partial void LogDiscordTokenExchangeFailed(ILogger logger, System.Net.HttpStatusCode status, string body); + [LoggerMessage(Level = LogLevel.Information, Message = "OIDC refresh tokens are enabled but the provider returned no refresh token (offline_access not granted?); falling back to a standard session.")] + private static partial void LogOidcRefreshUnavailable(ILogger logger); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch Poracle config for admin check for {UserId}.")] private static partial void LogPoracleConfigFetchFailed(ILogger logger, Exception ex, string userId); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs index 9e3ed4d0..4daad6a8 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs @@ -66,6 +66,21 @@ public async Task UpdateLocation([FromBody] LocationUpdateRequest }); } + [HttpGet("language")] + public async Task GetLanguage() + { + var human = await this._humanService.GetByIdAndProfileAsync(this.UserId, this.ProfileNo); + if (human == null) + { + return this.NotFound(); + } + + return this.Ok(new + { + language = human.Language + }); + } + [HttpPut("language")] public async Task UpdateLanguage([FromBody] LanguageUpdateRequest request) { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs index 641e5315..a527210e 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs @@ -1,72 +1,89 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Api.Controllers; - -[Route("api/masterdata")] -public class MasterDataController(IMasterDataService masterDataService, IPoracleApiProxy poracleApiProxy) : BaseApiController -{ - private readonly IMasterDataService _masterDataService = masterDataService; - private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; - - [AllowAnonymous] - [HttpGet("pokemon")] - public async Task GetPokemon() - { - var data = await this._masterDataService.GetPokemonDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetPokemonDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Pokemon data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("items")] - public async Task GetItems() - { - var data = await this._masterDataService.GetItemDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetItemDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Item data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("grunts")] - public async Task GetGrunts() - { - var grunts = await this._poracleApiProxy.GetGruntsAsync(); - if (grunts == null) - { - return this.NotFound(new - { - message = "Grunt data not available." - }); - } - - return this.Content(grunts, "application/json"); - } -} +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +[Route("api/masterdata")] +public class MasterDataController( + IMasterDataService masterDataService, + IPoracleApiProxy poracleApiProxy, + IRaidLevelService raidLevelService) : BaseApiController +{ + private readonly IMasterDataService _masterDataService = masterDataService; + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IRaidLevelService _raidLevelService = raidLevelService; + + [AllowAnonymous] + [HttpGet("pokemon")] + public async Task GetPokemon() + { + var data = await this._masterDataService.GetPokemonDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetPokemonDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Pokemon data not available." + }); + } + + return this.Content(data, "application/json"); + } + + [AllowAnonymous] + [HttpGet("items")] + public async Task GetItems() + { + var data = await this._masterDataService.GetItemDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetItemDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Item data not available." + }); + } + + return this.Content(data, "application/json"); + } + + /// + /// Canonical raid-level vocabulary (currently 19 levels from the WatWowMap masterfile). + /// Cached server-side; the frontend uses this to render the level selector and + /// fall back to bare integers for any level not in the list. + /// + [AllowAnonymous] + [HttpGet("raid-levels")] + public async Task GetRaidLevels() + { + var levels = await this._raidLevelService.GetAllAsync(); + return this.Ok(levels); + } + + [AllowAnonymous] + [HttpGet("grunts")] + public async Task GetGrunts() + { + var grunts = await this._poracleApiProxy.GetGruntsAsync(); + if (grunts == null) + { + return this.NotFound(new + { + message = "Grunt data not available." + }); + } + + return this.Content(grunts, "application/json"); + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs index ca231f56..1c34f23d 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs @@ -43,7 +43,7 @@ public async Task Create([FromBody] Profile profile) var maxNo = existing.Any() ? existing.Max(p => p.ProfileNo) : 0; profile.ProfileNo = maxNo + 1; - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); @@ -74,7 +74,7 @@ public async Task Update(int profileNo, [FromBody] Profile profil return this.NotFound(); } - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); @@ -178,88 +178,6 @@ public async Task Delete(int profileNo) return this.NoContent(); } - - internal static (bool IsValid, string? Error) ValidateActiveHours(string? activeHours) - { - if (string.IsNullOrWhiteSpace(activeHours)) - { - return (true, null); - } - - activeHours = activeHours.Trim(); - - JsonElement arr; - try - { - arr = JsonSerializer.Deserialize(activeHours); - } - catch (JsonException) - { - return (false, "active_hours must be a valid JSON array."); - } - - if (arr.ValueKind != JsonValueKind.Array) - { - return (false, "active_hours must be a JSON array."); - } - - if (arr.GetArrayLength() > 28) - { - return (false, "active_hours may contain at most 28 entries."); - } - - foreach (var entry in arr.EnumerateArray()) - { - if (entry.ValueKind != JsonValueKind.Object) - { - return (false, "Each active_hours entry must be an object."); - } - - if (!entry.TryGetProperty("day", out var dayProp) || !TryGetIntValue(dayProp, out var day) || day < 1 || day > 7) - { - return (false, "Each active_hours entry must have a 'day' between 1 and 7."); - } - - if (!entry.TryGetProperty("hours", out var hoursProp)) - { - return (false, "Each active_hours entry must have an 'hours' property."); - } - - if (!TryGetIntValue(hoursProp, out var hours) || hours < 0 || hours > 23) - { - return (false, "Each active_hours entry must have 'hours' between 0 and 23."); - } - - if (!entry.TryGetProperty("mins", out var minsProp)) - { - return (false, "Each active_hours entry must have a 'mins' property."); - } - - if (!TryGetIntValue(minsProp, out var mins) || mins < 0 || mins > 59) - { - return (false, "Each active_hours entry must have 'mins' between 0 and 59."); - } - } - - return (true, null); - } - - private static bool TryGetIntValue(JsonElement element, out int value) - { - if (element.ValueKind == JsonValueKind.Number) - { - return element.TryGetInt32(out value); - } - - if (element.ValueKind == JsonValueKind.String && - int.TryParse(element.GetString(), out value)) - { - return true; - } - - value = 0; - return false; - } } public class DuplicateProfileRequest diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs index 1ee078fb..41ceebab 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs @@ -13,7 +13,9 @@ public class SettingsController( ISiteSettingService siteSettingService, IOptions discordSettings, IOptions poracleSettings, - IOptions telegramSettings) : BaseApiController + IOptions telegramSettings, + IOptions oidcSettings, + IConfiguration configuration) : BaseApiController { private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) { @@ -32,6 +34,7 @@ public class SettingsController( private readonly DiscordSettings _discordSettings = discordSettings.Value; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly TelegramSettings _telegramSettings = telegramSettings.Value; + private readonly OidcSettings _oidcSettings = oidcSettings.Value; private readonly ISiteSettingService _siteSettingService = siteSettingService; [HttpGet] @@ -95,6 +98,52 @@ public IActionResult GetTelegramConfig() }); } + /// + /// Returns the server-side OIDC provider configuration (env / appsettings) for the admin + /// settings UI to display read-only. Secrets are masked; the client secret is never returned + /// in full. configured reflects whether the full provider config is present, and + /// forceLocal surfaces the AUTH_FORCE_LOCAL break-glass so the UI can explain why + /// OIDC may be inactive even when enabled. + /// + [HttpGet("oidc-config")] + public IActionResult GetOidcConfig() + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + var configured = !string.IsNullOrEmpty(this._oidcSettings.ClientId) + && !string.IsNullOrEmpty(this._oidcSettings.AuthorizationUrl) + && !string.IsNullOrEmpty(this._oidcSettings.TokenUrl) + && !string.IsNullOrEmpty(this._oidcSettings.UserInfoUrl); + + return this.Ok(new + { + configured, + enabled = this._oidcSettings.Enabled, + forceLocal = configuration.GetValue("Auth:ForceLocal"), + providerName = this._oidcSettings.ProviderName, + authorizationUrl = this._oidcSettings.AuthorizationUrl, + tokenUrl = this._oidcSettings.TokenUrl, + userInfoUrl = this._oidcSettings.UserInfoUrl, + endSessionUrl = this._oidcSettings.EndSessionUrl, + clientId = MaskValue(this._oidcSettings.ClientId), + clientSecret = MaskSecret(this._oidcSettings.ClientSecret), + scopes = this._oidcSettings.Scopes, + identityClaim = this._oidcSettings.IdentityClaim, + usePkce = this._oidcSettings.UsePkce, + // Refresh-token consumption (server-side config only — controlled by OIDC_USE_REFRESH_TOKENS; + // there is no runtime admin toggle, as refresh is coupled to the per-login JWT lifetime). + useRefreshTokens = this._oidcSettings.UseRefreshTokens, + accessTokenMinutes = this._oidcSettings.AccessTokenMinutes, + refreshTokenLifetimeDays = this._oidcSettings.RefreshTokenLifetimeDays, + revokedRetentionDays = this._oidcSettings.RevokedRetentionDays, + offlineAccessScope = this._oidcSettings.OfflineAccessScope, + tokenEndpointAuthMethod = this._oidcSettings.TokenEndpointAuthMethod, + }); + } + [HttpPut("{key}")] public async Task Upsert(string key, [FromBody] SiteSettingRequest request) { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs new file mode 100644 index 00000000..992906ae --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Pgan.PoracleWebNet.Api.Filters; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +/// +/// Per-user quest summary delivery schedules. The schedule is an active_hours array keyed by the +/// authenticated user + quest — there is NO id/userId route segment or body field anywhere +/// (the JWT's userId is the sole id source; summary_schedules is keyed per-user with no profile_no, +/// so any forwarded request-supplied id would be a full read/write/delete/trigger IDOR). The whole +/// controller is gated by disable_quests; admins are intentionally NOT exempt (see #236). +/// +[Route("api/summary-schedules")] +[RequireFeatureEnabled(DisableFeatureKeys.Quests)] +public class SummaryScheduleController( + IPoracleSummaryProxy summaryProxy, + ISummaryCapabilityService capability) : BaseApiController +{ + // Case-insensitive on purpose (deliberate upgrade over TestAlertController's case-sensitive set). + private static readonly HashSet ValidAlertTypes = new(StringComparer.OrdinalIgnoreCase) + { + "quest" + }; + + private readonly IPoracleSummaryProxy _summaryProxy = summaryProxy; + private readonly ISummaryCapabilityService _capability = capability; + + /// + /// Returns whether quest summary delivery is enabled on this server, as a 200-body boolean sourced + /// from the config flag (tracking.quest_summary_enabled). Degrades to enabled:false on + /// any fault — never returns 5xx — so a transient outage is never mistaken for "feature off". + /// + [HttpGet("capability")] + public async Task GetCapability() => this.Ok(new + { + enabled = await this._capability.IsQuestSummaryEnabledAsync() + }); + + /// Lists every summary schedule for the authenticated user, across alert types. + [HttpGet] + public async Task GetSchedules() + { + var schedulesJson = await this._summaryProxy.GetSchedulesAsync(this.UserId); + if (schedulesJson is not { ValueKind: JsonValueKind.Array } array) + { + return this.Ok(Array.Empty()); + } + + var schedules = new List(); + foreach (var element in array.EnumerateArray()) + { + schedules.Add(MapSchedule(element)); + } + + return this.Ok(schedules); + } + + /// + /// Gets the schedule for one alert type. When no schedule exists yet this returns 200 with an + /// empty schedule (active_hours = []) rather than 404 — "no schedule yet" is a normal + /// empty state, not an error, and a 404 would trip the SPA's global not-found toast. Consistent + /// with returning an empty array. + /// + [HttpGet("{alertType}")] + public async Task GetSchedule(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + var scheduleJson = await this._summaryProxy.GetScheduleAsync(this.UserId, alertType); + if (scheduleJson is not { ValueKind: JsonValueKind.Object } element) + { + return this.Ok(new SummarySchedule { AlertType = alertType, ActiveHours = "[]" }); + } + + return this.Ok(MapSchedule(element)); + } + + /// + /// Creates or replaces the schedule for one alert type (upsert). The DTO carries ONLY + /// ActiveHours — any id/userId/alertType body field is ignored; the route's validated + /// {alertType} is the sole alert-type source. Validates the active-hours payload via the + /// shared BEFORE proxying; null/whitespace clears the schedule. + /// + [HttpPut("{alertType}")] + [EnableRateLimiting("auth-read")] + public async Task SetSchedule(string alertType, [FromBody] SummaryScheduleRequest request) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + var (isValid, error) = ActiveHoursValidator.Validate(request.ActiveHours); + if (!isValid) + { + return this.BadRequest(new + { + error + }); + } + + var activeHours = string.IsNullOrWhiteSpace(request.ActiveHours) ? "[]" : request.ActiveHours; + await this._summaryProxy.SetScheduleAsync(this.UserId, alertType, activeHours); + return this.NoContent(); + } + + /// Removes the schedule for one alert type. Idempotent — succeeds even when absent. + [HttpDelete("{alertType}")] + [EnableRateLimiting("auth-read")] + public async Task DeleteSchedule(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + await this._summaryProxy.DeleteScheduleAsync(this.UserId, alertType); + return this.NoContent(); + } + + /// + /// Flush-and-deliver-now: re-enriches the buffered quests, renders, and delivers the summary DM + /// synchronously, then clears the bucket. Rate-limited (5/60s) and client-cooldown-guarded so a + /// double-click cannot double-deliver. + /// + [HttpPost("{alertType}/trigger")] + [EnableRateLimiting("test-alert")] + public async Task Trigger(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + await this._summaryProxy.TriggerAsync(this.UserId, alertType); + return this.NoContent(); + } + + // Maps the upstream { id, alert_type, active_hours } element to SummarySchedule. + // MUST NOT read or echo the upstream "id" — it is the user id (IDOR leak). + private static SummarySchedule MapSchedule(JsonElement element) + { + var alertType = element.TryGetProperty("alert_type", out var alertTypeProp) && alertTypeProp.ValueKind == JsonValueKind.String + ? alertTypeProp.GetString() ?? "quest" + : "quest"; + + var activeHours = "[]"; + if (element.TryGetProperty("active_hours", out var activeHoursProp)) + { + activeHours = activeHoursProp.ValueKind == JsonValueKind.String + ? activeHoursProp.GetString() ?? "[]" + : activeHoursProp.GetRawText(); + } + + return new SummarySchedule + { + AlertType = alertType, + ActiveHours = activeHours + }; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs index a25ec969..3a95a34f 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -30,6 +31,7 @@ public async Task GetCustomGeofences() } [HttpPost("custom")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task CreateGeofence([FromBody] UserGeofenceCreate model) { try @@ -70,6 +72,7 @@ public async Task DeleteGeofence(int id) } [HttpPost("custom/{kojiName}/submit")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task SubmitForReview(string kojiName) { try @@ -171,6 +174,7 @@ public async Task ExportGeoJson() } [HttpPost("import/geojson")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] [EnableRateLimiting("geojson-import")] [RequestSizeLimit(5 * 1024 * 1024)] public async Task ImportGeoJson(IFormFile file) diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs new file mode 100644 index 00000000..38cb2eb3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Maps thrown from PoracleSummaryProxy into a +/// generic HTTP 503. The response body carries no upstream URL, no X-Poracle-Secret, no +/// ex.Message, and no stack trace — the SPA treats this as a transient "try again later" banner, +/// NOT as "feature off" (the config-flag capability boolean is the only "feature off" source). +/// Registered globally in Program.cs next to FeatureDisabledExceptionFilter. +/// +public sealed class SummaryBackendUnavailableExceptionFilter : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + if (context.Exception is not SummaryBackendUnavailableException) + { + return; + } + + context.Result = new ObjectResult(new + { + error = "Quest summary service unavailable." + }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + context.ExceptionHandled = true; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj index ebc4f244..6b4960b5 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj +++ b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj @@ -11,9 +11,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Applications/Pgan.PoracleWebNet.Api/Program.cs b/Applications/Pgan.PoracleWebNet.Api/Program.cs index 4c180ace..f887c8fd 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Program.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Program.cs @@ -57,6 +57,31 @@ MapEnvVar("TELEGRAM_ENABLED", "Telegram__Enabled"); MapEnvVar("TELEGRAM_BOT_TOKEN", "Telegram__BotToken"); MapEnvVar("TELEGRAM_BOT_USERNAME", "Telegram__BotUsername"); +MapEnvVar("OIDC_ENABLED", "Oidc__Enabled"); +MapEnvVar("OIDC_PROVIDER_NAME", "Oidc__ProviderName"); +MapEnvVar("OIDC_AUTHORIZATION_URL", "Oidc__AuthorizationUrl"); +MapEnvVar("OIDC_TOKEN_URL", "Oidc__TokenUrl"); +MapEnvVar("OIDC_END_SESSION_URL", "Oidc__EndSessionUrl"); +MapEnvVar("OIDC_USERINFO_URL", "Oidc__UserInfoUrl"); +MapEnvVar("OIDC_CLIENT_ID", "Oidc__ClientId"); +MapEnvVar("OIDC_CLIENT_SECRET", "Oidc__ClientSecret"); +MapEnvVar("OIDC_SCOPES", "Oidc__Scopes"); +MapEnvVar("OIDC_IDENTITY_CLAIM", "Oidc__IdentityClaim"); +MapEnvVar("OIDC_USERNAME_CLAIM", "Oidc__UsernameClaim"); +MapEnvVar("OIDC_AVATAR_CLAIM", "Oidc__AvatarClaim"); +MapEnvVar("OIDC_IDENTITY_TYPE", "Oidc__IdentityType"); +MapEnvVar("OIDC_USE_PKCE", "Oidc__UsePkce"); +// Refresh-token consumption (opt-in, default off). When on, PoracleWeb brokers the provider's +// refresh token server-side for silent renewal + revocation propagation. Provider-agnostic. +MapEnvVar("OIDC_USE_REFRESH_TOKENS", "Oidc__UseRefreshTokens"); +MapEnvVar("OIDC_ACCESS_TOKEN_MINUTES", "Oidc__AccessTokenMinutes"); +MapEnvVar("OIDC_REFRESH_TOKEN_LIFETIME_DAYS", "Oidc__RefreshTokenLifetimeDays"); +MapEnvVar("OIDC_SESSION_REVOKED_RETENTION_DAYS", "Oidc__RevokedRetentionDays"); +MapEnvVar("OIDC_OFFLINE_ACCESS_SCOPE", "Oidc__OfflineAccessScope"); +MapEnvVar("OIDC_TOKEN_AUTH_METHOD", "Oidc__TokenEndpointAuthMethod"); +// Break-glass: forces the local login page regardless of the OIDC sign-in mode. Recovery +// path when an admin switches to OIDC against a broken/unreachable provider and gets locked out. +MapEnvVar("AUTH_FORCE_LOCAL", "Auth__ForceLocal"); MapEnvVar("PORACLE_API_ADDRESS", "Poracle__ApiAddress"); MapEnvVar("PORACLE_API_SECRET", "Poracle__ApiSecret"); MapEnvVar("PORACLE_ADMIN_IDS", "Poracle__AdminIds"); @@ -87,6 +112,18 @@ Environment.SetEnvironmentVariable("Telegram__Enabled", "true"); } +// Auto-infer OIDC__Enabled=true when the full provider config is present but Enabled was +// not explicitly set — same first-time-setup safeguard as Telegram above. +var oidcEnabled = Environment.GetEnvironmentVariable("Oidc__Enabled"); +if (string.IsNullOrEmpty(oidcEnabled) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__ClientId")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__AuthorizationUrl")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__TokenUrl")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__UserInfoUrl"))) +{ + Environment.SetEnvironmentVariable("Oidc__Enabled", "true"); +} + // Reload configuration after env var bridging builder.Configuration.AddEnvironmentVariables(); @@ -137,7 +174,11 @@ // Add controllers. The global FeatureDisabledExceptionFilter maps any FeatureDisabledException // thrown from a service into HTTP 403 — covers callers that bypass [RequireFeatureEnabled] // (e.g. QuickPickService → MonsterService.CreateAsync). See #236. -builder.Services.AddControllers(options => options.Filters.Add()); +builder.Services.AddControllers(options => +{ + options.Filters.Add(); + options.Filters.Add(); +}); // Add Poracle services (DbContext, repositories, services, settings) builder.Services.AddPoracleServices(builder.Configuration); @@ -146,6 +187,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // JWT Authentication var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs new file mode 100644 index 00000000..819509fb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// The token/userinfo result from the external OIDC provider. is +/// nullable: a provider may issue one only with offline_access, and on a refresh grant a +/// non-rotating provider returns none (the caller then keeps reusing the prior token). +/// is nullable for providers that omit it. +/// +public sealed record OidcTokenResult(string AccessToken, string? RefreshToken, int? ExpiresIn); + +/// +/// Provider-agnostic HTTP client for the external OIDC endpoints. Encapsulates the +/// authorization-code exchange, the refresh-token grant, and the userinfo fetch — including the +/// configurable token-endpoint client-authentication method (client_secret_post vs +/// client_secret_basic). Relies only on spec-standard OAuth2/OIDC; no discovery, JWKS, or +/// id_token required. +/// +public interface IOidcClient +{ + /// Exchanges an authorization code for tokens. Returns null on any provider error. + Task ExchangeCodeAsync(string code, string redirectUri, string? codeVerifier); + + /// Redeems a refresh token (grant_type=refresh_token). Returns null on any provider error. + Task RefreshAsync(string refreshToken); + + /// Fetches the userinfo claims with the given access token. Returns null on failure. + Task GetUserInfoAsync(string accessToken); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs new file mode 100644 index 00000000..6b9a4fcf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs @@ -0,0 +1,60 @@ +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Carries the state of an in-progress refresh rotation between +/// and its completion. The presented row is +/// already revoked when this is returned; the caller must either +/// (success) or +/// (provider/userinfo failure). +/// +public sealed class OidcRotationTicket +{ + public required string UserId { get; init; } + public required string FamilyId { get; init; } + public required DateTime FamilyIssuedAt { get; init; } + + /// The provider refresh token decrypted from the presented session, for the IdP refresh call. + public required string DecryptedRefreshToken { get; init; } + + /// The new opaque token to hand back to the browser once the successor row is persisted. + public required string NewOpaqueToken { get; init; } + + /// SHA-256 of — the successor row's primary lookup key (internal). + public required string NewTokenHash { get; init; } +} + +/// +/// Server-side mechanics for OIDC refresh sessions: opaque-token issuance, encrypted storage of +/// the provider refresh token, atomic rotation, replay/family-revoke, and absolute-cap enforcement. +/// Provider-agnostic and orchestrated by AuthController (which owns userinfo re-validation +/// and role resolution). All "invalid" conditions throw +/// with message invalid_grant. +/// +public interface IOidcSessionService +{ + /// + /// Creates a new rotation family for a freshly authenticated user and returns the opaque token + /// to embed in the login callback. The provider refresh token is encrypted at rest. + /// + Task IssueAsync(string userId, string idpRefreshToken, string? ipAddress, string? userAgent); + + /// + /// Validates the presented opaque token (active, not replayed, within the absolute cap), then + /// atomically revokes it and reserves a successor. Throws + /// on replay (revoking the whole family), expiry, or cap. The caller then performs the IdP + /// refresh + userinfo re-validation using . + /// + Task StartRotationAsync(string opaqueToken, string? ipAddress, string? userAgent); + + /// Persists the successor session row (encrypting the carried-forward provider refresh token). + Task CompleteRotationAsync(OidcRotationTicket ticket, string newIdpRefreshToken); + + /// Revokes the whole family when the IdP refresh or userinfo re-validation fails mid-rotation. + Task AbortRotationAsync(OidcRotationTicket ticket, string reason); + + /// Revokes the family the presented opaque token belongs to (logout). Safe on unknown tokens. + Task RevokeAsync(string opaqueToken, string reason); + + /// Revokes every active session for a user (admin disable / logout-everywhere). + Task RevokeAllForUserAsync(string userId, string reason); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs new file mode 100644 index 00000000..7dd893a3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs @@ -0,0 +1,122 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Provider-agnostic OIDC HTTP client. See . All provider divergences +/// (token-endpoint auth method, optional/non-rotating refresh tokens, missing expires_in) are +/// handled here so callers (login callback + refresh service) share one identical code path. +/// +public sealed partial class OidcClient( + HttpClient httpClient, + IOptions oidcSettings, + ILogger logger) : IOidcClient +{ + private readonly HttpClient _httpClient = httpClient; + private readonly OidcSettings _settings = oidcSettings.Value; + private readonly ILogger _logger = logger; + + public async Task ExchangeCodeAsync(string code, string redirectUri, string? codeVerifier) + { + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri, + }; + + if (this._settings.UsePkce && !string.IsNullOrEmpty(codeVerifier)) + { + form["code_verifier"] = codeVerifier; + } + + return await this.PostTokenAsync(form, "authorization_code"); + } + + public async Task RefreshAsync(string refreshToken) + { + var form = new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + }; + + return await this.PostTokenAsync(form, "refresh_token"); + } + + public async Task GetUserInfoAsync(string accessToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, this._settings.UserInfoUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await this._httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + LogUserInfoFailed(this._logger, response.StatusCode, body); + return null; + } + + return await response.Content.ReadFromJsonAsync(); + } + + /// + /// Posts a token request, applying the configured client-authentication method, and parses + /// the standard OAuth2 token response. Returns null on transport/HTTP error or a missing + /// access_token. + /// + private async Task PostTokenAsync(Dictionary form, string grant) + { + using var request = new HttpRequestMessage(HttpMethod.Post, this._settings.TokenUrl); + + // Identify the client. With client_secret_basic the secret rides in the Authorization + // header; with client_secret_post both id and secret go in the body. + form["client_id"] = this._settings.ClientId; + + if (string.Equals(this._settings.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.OrdinalIgnoreCase)) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{this._settings.ClientId}:{this._settings.ClientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + else + { + form["client_secret"] = this._settings.ClientSecret; + } + + request.Content = new FormUrlEncodedContent(form); + + using var response = await this._httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + LogTokenFailed(this._logger, grant, response.StatusCode, body); + return null; + } + + var json = await response.Content.ReadFromJsonAsync(); + if (!json.TryGetProperty("access_token", out var accessTokenProp) || + accessTokenProp.GetString() is not { Length: > 0 } accessToken) + { + LogTokenFailed(this._logger, grant, response.StatusCode, "response had no access_token"); + return null; + } + + var refreshToken = json.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null; + int? expiresIn = json.TryGetProperty("expires_in", out var expProp) && expProp.ValueKind == JsonValueKind.Number + ? expProp.GetInt32() + : null; + + return new OidcTokenResult(accessToken, string.IsNullOrEmpty(refreshToken) ? null : refreshToken, expiresIn); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC {Grant} token request failed: {Status} {Body}")] + private static partial void LogTokenFailed(ILogger logger, string grant, System.Net.HttpStatusCode status, string body); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC userinfo fetch failed: {Status} {Body}")] + private static partial void LogUserInfoFailed(ILogger logger, System.Net.HttpStatusCode status, string body); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs new file mode 100644 index 00000000..7ca93a95 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Periodically deletes expired and long-revoked OIDC refresh sessions with a single set-based +/// delete. Only does work when refresh consumption is enabled; otherwise the table stays empty +/// and each pass is a cheap no-op. Runs every 6 hours; revoked rows are retained for the same +/// number of days as the session cap (for audit/replay-forensics) before deletion. +/// +public sealed partial class OidcSessionCleanupService( + IServiceScopeFactory scopeFactory, + IOptions oidcSettings, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + + private readonly OidcSettings _settings = oidcSettings.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Small initial stagger so startup isn't contended. + await Task.Delay(TimeSpan.FromSeconds(240), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var retention = TimeSpan.FromDays(Math.Max(1, this._settings.RevokedRetentionDays)); + var deleted = await repo.DeleteExpiredAndStaleAsync(retention); + if (deleted > 0) + { + LogCleanup(logger, deleted); + } + } + catch (Exception ex) + { + LogCleanupFailed(logger, ex); + } + + await Task.Delay(Interval, stoppingToken); + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "OIDC session cleanup removed {Count} expired/stale rows.")] + private static partial void LogCleanup(ILogger logger, int count); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC session cleanup failed; will retry next interval.")] + private static partial void LogCleanupFailed(ILogger logger, Exception ex); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs new file mode 100644 index 00000000..ceacb863 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// See . The opaque token is 32 bytes of CSPRNG entropy +/// (base64url); only its SHA-256 hash is stored. The provider refresh token is encrypted with +/// DataProtection (purpose-scoped) and never leaves the server. +public sealed partial class OidcSessionService : IOidcSessionService +{ + private const string ProtectorPurpose = "Pgan.PoracleWebNet.OidcRefresh.v1"; + + private readonly IOidcSessionRepository _sessions; + private readonly IDataProtector _protector; + private readonly OidcSettings _settings; + private readonly ILogger _logger; + + public OidcSessionService( + IOidcSessionRepository sessions, + IDataProtectionProvider dataProtectionProvider, + IOptions oidcSettings, + ILogger logger) + { + this._sessions = sessions; + this._protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); + this._settings = oidcSettings.Value; + this._logger = logger; + } + + public async Task IssueAsync(string userId, string idpRefreshToken, string? ipAddress, string? userAgent) + { + var opaque = GenerateOpaqueToken(); + var now = DateTime.UtcNow; + + await this._sessions.AddAsync(new OidcSession + { + SessionTokenHash = HashToken(opaque), + FamilyId = Guid.NewGuid().ToString(), + FamilyIssuedAt = now, + UserId = userId, + EncryptedRefreshToken = this._protector.Protect(idpRefreshToken), + ExpiresAt = now.AddDays(this._settings.RefreshTokenLifetimeDays), + CreatedUtc = now, + IpAddress = ipAddress, + UserAgent = userAgent, + }); + + return opaque; + } + + public async Task StartRotationAsync(string opaqueToken, string? ipAddress, string? userAgent) + { + var hash = HashToken(opaqueToken); + var session = await this._sessions.GetByHashAsync(hash); + if (session is null) + { + throw InvalidGrant(); + } + + // Replay: a presented-but-already-revoked token revokes the whole family. + if (session.RevokedAt is not null) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "replay_detected"); + LogReplayDetected(this._logger, HashPrefix(hash)); + throw InvalidGrant(); + } + + var now = DateTime.UtcNow; + + // Absolute cap: a family cannot be refreshed past FamilyIssuedAt + RefreshTokenLifetimeDays. + if (session.FamilyIssuedAt.AddDays(this._settings.RefreshTokenLifetimeDays) <= now) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "absolute_cap"); + throw InvalidGrant(); + } + + // Plain expiry (no family revoke). + if (session.ExpiresAt <= now) + { + throw InvalidGrant(); + } + + var newOpaque = GenerateOpaqueToken(); + var newHash = HashToken(newOpaque); + + // Atomic guard: revokes the presented row only if still active. 0 ⇒ a concurrent refresh + // already rotated it — treat as replay and revoke the family. + var affected = await this._sessions.TryRevokeForRotationAsync(hash, newHash); + if (affected == 0) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "replay_detected"); + LogReplayDetected(this._logger, HashPrefix(hash)); + throw InvalidGrant(); + } + + string decrypted; + try + { + decrypted = this._protector.Unprotect(session.EncryptedRefreshToken); + } + catch (CryptographicException) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "decrypt_failed"); + throw InvalidGrant(); + } + + return new OidcRotationTicket + { + UserId = session.UserId, + FamilyId = session.FamilyId, + FamilyIssuedAt = session.FamilyIssuedAt, + DecryptedRefreshToken = decrypted, + NewOpaqueToken = newOpaque, + NewTokenHash = newHash, + }; + } + + public async Task CompleteRotationAsync(OidcRotationTicket ticket, string newIdpRefreshToken) + { + var now = DateTime.UtcNow; + await this._sessions.AddAsync(new OidcSession + { + SessionTokenHash = ticket.NewTokenHash, + FamilyId = ticket.FamilyId, + FamilyIssuedAt = ticket.FamilyIssuedAt, + UserId = ticket.UserId, + EncryptedRefreshToken = this._protector.Protect(newIdpRefreshToken), + // Fixed window: successor expires at the family's absolute cap. + ExpiresAt = ticket.FamilyIssuedAt.AddDays(this._settings.RefreshTokenLifetimeDays), + CreatedUtc = now, + }); + } + + public async Task AbortRotationAsync(OidcRotationTicket ticket, string reason) => + await this._sessions.RevokeFamilyAsync(ticket.FamilyId, reason); + + public async Task RevokeAsync(string opaqueToken, string reason) + { + if (string.IsNullOrEmpty(opaqueToken)) + { + return; + } + + var session = await this._sessions.GetByHashAsync(HashToken(opaqueToken)); + if (session is not null) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, reason); + } + } + + public async Task RevokeAllForUserAsync(string userId, string reason) => + await this._sessions.RevokeAllForUserAsync(userId, reason); + + private static string GenerateOpaqueToken() => + Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + + private static string HashToken(string token) => + Convert.ToHexStringLower(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); + + private static string HashPrefix(string hash) => hash.Length <= 8 ? hash : hash[..8]; + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static UnauthorizedAccessException InvalidGrant() => new("invalid_grant"); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC refresh replay detected for session {HashPrefix}; family revoked.")] + private static partial void LogReplayDetected(ILogger logger, string hashPrefix); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 6184b7f6..f5772701 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -8,15 +8,15 @@ "name": "client-app", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.8", - "@angular/cdk": "^21.2.6", - "@angular/common": "^21.2.8", - "@angular/compiler": "^21.2.8", - "@angular/core": "^21.2.8", - "@angular/forms": "^21.2.8", - "@angular/material": "^21.2.6", - "@angular/platform-browser": "^21.2.8", - "@angular/router": "^21.2.8", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.16", + "@angular/compiler": "^21.2.16", + "@angular/core": "^21.2.16", + "@angular/forms": "^21.2.16", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.16", + "@angular/router": "^21.2.16", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -33,40 +33,33 @@ "@angular-eslint/eslint-plugin-template": "^19.0.0", "@angular-eslint/schematics": "^19.0.0", "@angular-eslint/template-parser": "^19.0.0", - "@angular/build": "^21.2.7", - "@angular/cli": "^21.2.7", - "@angular/compiler-cli": "^21.2.8", - "@angular/platform-browser-dynamic": "^21.2.8", - "@jest/globals": "^30.3.0", + "@angular/build": "^21.2.14", + "@angular/cli": "^21.2.14", + "@angular/compiler-cli": "^21.2.16", + "@angular/platform-browser-dynamic": "^21.2.16", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.60.1", "@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", - "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", - "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^16.1.5", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", "prettier-eslint": "^16.4.0", "ts-node": "^10.9.2", "typescript": "~5.9.2" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -334,40 +327,6 @@ } } }, - "node_modules/@angular-devkit/architect/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -389,9 +348,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz", - "integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.14.tgz", + "integrity": "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA==", "dev": true, "license": "MIT", "dependencies": { @@ -463,40 +422,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -560,40 +485,6 @@ } } }, - "node_modules/@angular-eslint/builder/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/builder/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/builder/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -701,40 +592,6 @@ } } }, - "node_modules/@angular-eslint/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -786,9 +643,9 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.8.tgz", - "integrity": "sha512-RIqfVmfretQ0x/mXgMXe7Bw0Tpe8+zBV/Mm2OaNVyrmNG+9gYItEn5t/ZnQGcPD5nMNqckgp3+4/ZMc/qkS5ww==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.16.tgz", + "integrity": "sha512-YPhph/OC1A0vkT95XZW6lXMNmi5ly91JeXi+5yeG8CCxfqscVfRNPsYbRWjSueO0cQT2HJ8U1CLteQ5a1OaoHA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -797,18 +654,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.8" + "@angular/core": "21.2.16" } }, "node_modules/@angular/build": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.7.tgz", - "integrity": "sha512-FpSkFqpsJtdN1cROekVYkmeV1QepdP+/d7fyYQEuNmlOlyqXSDh9qJmy4iL9VNbAU0rk+vFCtYM86rO7Pt9cSw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.14.tgz", + "integrity": "sha512-l8JB326iIwum2WmbopUUFdiuYsbHchix6MH8o6F6FA7LJr8QLTvipwwbw+Jx31/RE50WkGmzsZ1fBDw/cMbmUw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.7", + "@angular-devkit/architect": "0.2102.14", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -851,7 +708,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.7", + "@angular/ssr": "^21.2.14", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -901,13 +758,13 @@ } }, "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2102.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz", - "integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==", + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.14", "rxjs": "7.8.2" }, "bin": { @@ -943,9 +800,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.6.tgz", - "integrity": "sha512-1PBzFf+um/VZ1dFF6cT72Zsq+9C/ZWF9m5dP0uHJgo4psX3yMBoZlZu5YomBiAQ/ePSkqCuryv1vrelK+yd3Mw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.14.tgz", + "integrity": "sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -959,19 +816,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.7.tgz", - "integrity": "sha512-N/wj8fFRB718efIFYpwnYfy+MecZREZXsUNMTVndFLH6T0jCheb9PVetR6jsyZp6h46USNPOmJYJ/9255lME+Q==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz", + "integrity": "sha512-S8jExTjxPJILwpg2lu3DohSASVZ8DLhSNCmOe7z0qF9VskRSjC7SIQv1rq36tsJkenxuA72gjVOHZv+uSRT8HA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.7", - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", + "@angular-devkit/architect": "0.2102.14", + "@angular-devkit/core": "21.2.14", + "@angular-devkit/schematics": "21.2.14", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.7", + "@schematics/angular": "21.2.14", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -994,13 +851,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.2102.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz", - "integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==", + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.14", "rxjs": "7.8.2" }, "bin": { @@ -1013,13 +870,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz", - "integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.14.tgz", + "integrity": "sha512-KMJlQSBEzI4+Cy1Zh72gmGQNN2I1vY+nj9CoRcZPBIi1si+0ZAc49XT85eYl+eQumNTVQviUG7LQqgLDAHml+g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.14", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -1160,9 +1017,9 @@ } }, "node_modules/@angular/cli/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -1193,9 +1050,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.8.tgz", - "integrity": "sha512-ZvgcxsLPkSG0B1jc2ZXshAWIFBoQ0U9uwIX/zG/RGcfMpoKyEDNAebli6FTIpxIlz/35rtBNV7EGPhinjPTJFQ==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.16.tgz", + "integrity": "sha512-htHNepKzjIjkc5BQ7MKDN0bVDOfQpFr/fGUxa6irC0kFLfWt7idUTdNcxypRvjCCTuBYHkjr74fH4QKu+qvPXg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1204,14 +1061,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.8", + "@angular/core": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.8.tgz", - "integrity": "sha512-Il9KlT6qX8rWmun5jY6wMLx56bCQZpOVIFEyHM4ai2wmxvbqyxgRFKDs4iMRNn1h04Tgupl6cKSqP9lecIvH6w==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.16.tgz", + "integrity": "sha512-hVjp93gYgNj5aRbCQUK7L+pOfdqk96lCtmSL2hOL725Pmib9NyNIrA3ISfAQHN+Qo70763WUZahOiqBBOzfAcg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1221,9 +1078,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.8.tgz", - "integrity": "sha512-S0W+6QazCsn/4xWZu0V5VmU9zmKIlqFR2FJSsAQUPReVmpA40SuQSP6A/cyMVIMYaHvO/cAXSHJVgpxBzBSL/Q==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.16.tgz", + "integrity": "sha512-w2ck3o+uw29AZEGK3HvOsF/ZRiPcfoq2TaDtiNjdH+svhwawt9PfMXrDbbIKF30prWzKLpT3UsCqTz1awv7Ubw==", "dev": true, "license": "MIT", "dependencies": { @@ -1244,7 +1101,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.8", + "@angular/compiler": "21.2.16", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1254,9 +1111,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.8.tgz", - "integrity": "sha512-hI7n4t8qgFJaVV55LIaNuzcdP+/IeuqQRu3huSLo47Gf6uZAD0Acj4Ye9SC8YNmhUu5/RiImngm9NOlcI2oCJA==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.16.tgz", + "integrity": "sha512-uufKORlB0jeYdqOvjAfMYgqIqmJentOj8XvTUxsFP5k85xxzXsDarSpP199YQz6jhJJQYNOWIloDkUTQJi5rNA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1265,7 +1122,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.8", + "@angular/compiler": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1279,9 +1136,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.8.tgz", - "integrity": "sha512-tyQAHjfMHcqETRkKQaZHjYqIK9W8uRenPpY2DF/Jl+S7CwcaX4T8t8TKgzvTynNzQW9QGiLg0pqVosVMKzBXJg==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.16.tgz", + "integrity": "sha512-2djTJmTpg/MkQ2kdCI9k0LT4RL9/Hg03fDUNN2eN5c04FIk99D3yHXUJYLwiaErLuLQNkU8HaijluKHdH93cWQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1291,22 +1148,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/material": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.6.tgz", - "integrity": "sha512-V4hblb5ekgXb5x+UXKRs2yiB0hZUkUJbYwGseMglkCeWQlLM4u6amlsUzP4uOwIWFOkM/ZYl9qz4YGZnvMAyjw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.14.tgz", + "integrity": "sha512-fMQca8VRtei93JRRG9qQ+u08DCb0nga59Esoakq5yx3+A1NfdpFeUS1tBns56U04o8KAaIAwZK3NBqXz8ZKNqg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "21.2.6", + "@angular/cdk": "21.2.14", "@angular/common": "^21.0.0 || ^22.0.0", "@angular/core": "^21.0.0 || ^22.0.0", "@angular/forms": "^21.0.0 || ^22.0.0", @@ -1315,9 +1172,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.8.tgz", - "integrity": "sha512-4fwmGf7GCuIsjFqx1gqqWC92YjlN9SmGJO17TPPsOm5zUOnDx+h3Bj9XjdXxlcBtugTb2xHk6Auqyv3lzWGlkw==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.16.tgz", + "integrity": "sha512-59ToWYDb+O3fS0+Y4ubQqV0zY6sf2esLZ19AT7JKXN7Akqbz7aQ2/3k3PKmfhwKWek5o3lkuNz8YhxKQruNh8Q==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1326,9 +1183,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.8", - "@angular/common": "21.2.8", - "@angular/core": "21.2.8" + "@angular/animations": "21.2.16", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1337,9 +1194,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.8.tgz", - "integrity": "sha512-9XeplSHsKnLDm14dvwXG00Ox6WbDrhf7ub7MxxcJ6gCgRm/yqJ3Vrz4a+NBpYnelapqiCCGEdHeyx2xt8vG1qA==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.16.tgz", + "integrity": "sha512-WtTnkJOmKiGccHRQfBdkwODAkpTB4zbPN3IKhcqCjlezKaPqZB5tjrIu72Z5pmi5VIgJz1LmfO1LSVCMC5h7dA==", "dev": true, "license": "MIT", "dependencies": { @@ -1349,16 +1206,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/compiler": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8" + "@angular/common": "21.2.16", + "@angular/compiler": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16" } }, "node_modules/@angular/router": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.8.tgz", - "integrity": "sha512-KSlUbFHHKY84G6iKlB2FDMmh+lLmGjmpyT1p/kx8qZm1BuxJGOOU+oNgkCfaPJT1R2/muDXuxQ51uc/la6y28g==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.16.tgz", + "integrity": "sha512-0+Pyh0uT4vCLabKoGCARYWlwpz4DgZI9AE01n8s9u/nKAZuEMnJtLLnaUtHEMI8nJSqpgnS/5AthuJZdDEfkYw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1367,21 +1224,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", - "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" }, @@ -1390,27 +1248,30 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -2027,9 +1888,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -2051,9 +1912,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -2068,7 +1929,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -2102,9 +1963,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "dev": true, "funding": [ { @@ -3427,17 +3288,17 @@ } }, "node_modules/@jest/console": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", - "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3445,38 +3306,39 @@ } }, "node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3492,9 +3354,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -3502,35 +3364,35 @@ } }, "node_modules/@jest/environment": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", - "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.3.0.tgz", - "integrity": "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3546,23 +3408,23 @@ } }, "node_modules/@jest/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3573,18 +3435,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", - "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3601,47 +3463,47 @@ } }, "node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/types": "30.3.0", - "jest-mock": "30.3.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", - "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -3654,9 +3516,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -3674,9 +3536,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3687,13 +3549,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", - "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -3718,14 +3580,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", - "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -3734,15 +3596,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", - "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3750,23 +3612,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -3783,14 +3645,14 @@ "license": "MIT" }, "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -5090,13 +4952,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -5688,14 +5550,14 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.7.tgz", - "integrity": "sha512-aqEj3RyBtmH+41HZvrbfrpCo0e+0NzwyQyNSC/wLDShVqoidBtPbEdHU1FZ4+ni41da7rI3F12gUuAHws27kMA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.14.tgz", + "integrity": "sha512-rIEdtNTdCCTwuo7B4tMoq5qmbLXdBgmW6Ays1hyno//4OE+HFtvlWZd+hl6KceEyN00IcZ2HRaPnfd71E1JnoA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", + "@angular-devkit/core": "21.2.14", + "@angular-devkit/schematics": "21.2.14", "jsonc-parser": "3.3.1" }, "engines": { @@ -5705,13 +5567,13 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz", - "integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.14.tgz", + "integrity": "sha512-KMJlQSBEzI4+Cy1Zh72gmGQNN2I1vY+nj9CoRcZPBIi1si+0ZAc49XT85eYl+eQumNTVQviUG7LQqgLDAHml+g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.14", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -5839,9 +5701,9 @@ } }, "node_modules/@schematics/angular/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -5969,9 +5831,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6248,17 +6110,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6271,22 +6133,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -6302,14 +6164,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -6324,14 +6186,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6342,9 +6204,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -6359,15 +6221,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6384,9 +6246,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -6398,16 +6260,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6426,9 +6288,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -6439,16 +6301,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6463,13 +6325,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7208,16 +7070,16 @@ } }, "node_modules/babel-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", - "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.3.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.3.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -7250,9 +7112,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", - "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -7290,13 +7152,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", - "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.3.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -8199,32 +8061,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -9006,9 +8842,9 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, "license": "ISC", "dependencies": { @@ -9167,13 +9003,13 @@ } }, "node_modules/eslint-plugin-perfectionist": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", - "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.9.0.tgz", + "integrity": "sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.58.0", + "@typescript-eslint/utils": "^8.58.2", "natural-orderby": "^5.0.0" }, "engines": { @@ -9184,14 +9020,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.11.13" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -9515,18 +9351,18 @@ } }, "node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -11320,16 +11156,16 @@ } }, "node_modules/jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", - "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.3.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -11347,14 +11183,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", - "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -11362,29 +11198,29 @@ } }, "node_modules/jest-circus": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", - "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "p-limit": "^3.1.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -11394,21 +11230,21 @@ } }, "node_modules/jest-cli": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", - "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -11511,33 +11347,33 @@ } }, "node_modules/jest-config": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", - "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.3.0", - "@jest/types": "30.3.0", - "babel-jest": "30.3.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.3.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-runner": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "parse-json": "^5.2.0", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -11562,25 +11398,25 @@ } }, "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", "dependencies": { @@ -11591,31 +11427,31 @@ } }, "node_modules/jest-each": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", - "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.3.0", - "pretty-format": "30.3.0" + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-jsdom": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.3.0.tgz", - "integrity": "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/environment-jsdom-abstract": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", "jsdom": "^26.1.0" }, "engines": { @@ -11954,39 +11790,39 @@ } }, "node_modules/jest-environment-node": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", - "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, @@ -11998,49 +11834,50 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", - "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -12049,15 +11886,15 @@ } }, "node_modules/jest-mock": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", - "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.3.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12082,9 +11919,9 @@ } }, "node_modules/jest-preset-angular": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-16.1.4.tgz", - "integrity": "sha512-9RAEcxejwhumdGhOabraQ6ZSNAKJfOHHeQpq47fYOfBNNl4CIQf9um7a6vGK2iGSxvo0tNzw1mNVlYWKkPWx1g==", + "version": "16.1.5", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-16.1.5.tgz", + "integrity": "sha512-4YNjA8O02TAQisr3JozsyFGQ4Dkc3FQyGebjpRZfXhiMo32arYW1bXZ5KXCjSe4OoXZrFV5T5nrV7SXr4NmBuA==", "dev": true, "license": "MIT", "dependencies": { @@ -12112,9 +11949,9 @@ } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -12122,18 +11959,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", - "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -12142,46 +11979,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", - "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.3.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", - "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/environment": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-leak-detector": "30.3.0", - "jest-message-util": "30.3.0", - "jest-resolve": "30.3.0", - "jest-runtime": "30.3.0", - "jest-util": "30.3.0", - "jest-watcher": "30.3.0", - "jest-worker": "30.3.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -12211,32 +12048,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", - "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/globals": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -12245,9 +12082,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", - "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -12256,20 +12093,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.3.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "pretty-format": "30.3.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -12278,13 +12115,13 @@ } }, "node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12296,18 +12133,18 @@ } }, "node_modules/jest-validate": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", - "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12327,19 +12164,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", - "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -12347,15 +12184,15 @@ } }, "node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -12410,36 +12247,36 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -12450,6 +12287,26 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14180,12 +14037,12 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -14233,12 +14090,12 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -14542,9 +14399,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -14812,15 +14669,16 @@ } }, "node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -14974,6 +14832,22 @@ "dev": true, "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -16016,9 +15890,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "dev": true, "license": "MIT", "engines": { @@ -16299,13 +16173,13 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index fd65c83a..cb3765cd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -15,15 +15,15 @@ "private": true, "packageManager": "npm@11.5.2", "dependencies": { - "@angular/animations": "^21.2.8", - "@angular/cdk": "^21.2.6", - "@angular/common": "^21.2.8", - "@angular/compiler": "^21.2.8", - "@angular/core": "^21.2.8", - "@angular/forms": "^21.2.8", - "@angular/material": "^21.2.6", - "@angular/platform-browser": "^21.2.8", - "@angular/router": "^21.2.8", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.16", + "@angular/compiler": "^21.2.16", + "@angular/core": "^21.2.16", + "@angular/forms": "^21.2.16", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.16", + "@angular/router": "^21.2.16", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -40,28 +40,28 @@ "@angular-eslint/eslint-plugin-template": "^19.0.0", "@angular-eslint/schematics": "^19.0.0", "@angular-eslint/template-parser": "^19.0.0", - "@angular/build": "^21.2.7", - "@angular/cli": "^21.2.7", - "@angular/compiler-cli": "^21.2.8", - "@angular/platform-browser-dynamic": "^21.2.8", - "@jest/globals": "^30.3.0", + "@angular/build": "^21.2.14", + "@angular/cli": "^21.2.14", + "@angular/compiler-cli": "^21.2.16", + "@angular/platform-browser-dynamic": "^21.2.16", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.60.1", "@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-perfectionist": "^5.9.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", - "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", - "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^16.1.5", + "jsdom": "^29.1.1", + "prettier": "^3.8.3", "prettier-eslint": "^16.4.0", "ts-node": "^10.9.2", "typescript": "~5.9.2" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json new file mode 100644 index 00000000..c2de9aaf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json @@ -0,0 +1,14 @@ +{ + "/api": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + }, + "/auth": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json new file mode 100644 index 00000000..e299aa76 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts index 9b21a077..2e8e5f3c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts @@ -8,12 +8,15 @@ import { TranslateHttpLoader, provideTranslateHttpLoader } from '@ngx-translate/ import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { errorInterceptor } from './core/interceptors/error.interceptor'; +import { oidcRefreshInterceptor } from './core/interceptors/oidc-refresh.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), + // oidcRefreshInterceptor sits closest to the backend so it catches a 401 (and can silently + // refresh + retry) before errorInterceptor redirects to the login page. + provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, oidcRefreshInterceptor])), provideAnimationsAsync(), provideTranslateService({ defaultLanguage: 'en', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html index 7ec28460..1c85d749 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html @@ -110,6 +110,10 @@ {{ 'MENU.CLEANING' | translate }} + + @if (ssoLogoutAvailable()) { + + } - }
@@ -21,77 +11,108 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} @else { - @for (group of visibleGroups(); track group.labelKey; let last = $last) { -
-
- {{ group.icon }} - + + + + +
+
+ vpn_key + +
+ +
+
+ {{ 'ADMIN_SETTINGS.AUTH_MODE_LABEL' | translate }} + {{ + (authMode() === 'oidc' ? 'ADMIN_SETTINGS.AUTH_MODE_OIDC_DESC' : 'ADMIN_SETTINGS.AUTH_MODE_LOCAL_DESC') | translate + }}
+ + + group + {{ 'ADMIN_SETTINGS.AUTH_MODE_LOCAL' | translate }} + + + vpn_key + {{ 'ADMIN_SETTINGS.AUTH_MODE_OIDC' | translate }} + + +
-
- @for (meta of group.settings; track meta.key; let rowLast = $last) { - @if (getSettingValue(meta.key) !== null && isSettingVisible(meta)) { -
-
- {{ meta.labelKey | translate }} - {{ meta.descriptionKey | translate }} -
-
- @if (meta.type === 'boolean') { - - } @else { - - - - @if (meta.key === 'favicon_url') { -
- favicon preview -
- } - } -
-
- @if (meta.key === 'favicon_url') { -
- info_outline - {{ 'ADMIN_SETTINGS.FAVICON_URL_CACHE_WARNING' | translate }} -
-
- info_outline - {{ 'ADMIN_SETTINGS.FAVICON_URL_CSP_NOTE' | translate }} -
- } - @if (!rowLast) { - - } - } - } + @if (!oidcConfigured()) { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_OIDC_NOT_CONFIGURED' | translate }} +
+ } + + @if (authMode() === 'oidc') { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_OIDC_HIDES_LOCAL' | translate }}
- @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) { - @if (!telegramConfig()!.enabled) { -
- info_outline - + @if (oidcConfig()?.forceLocal) { +
+ warning + {{ 'ADMIN_SETTINGS.AUTH_FORCE_LOCAL_ACTIVE' | translate }} +
+ } + + @if (oidcEndSessionConfigured()) { +
+
+ {{ 'ADMIN_SETTINGS.AUTH_SLO_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.AUTH_SLO_DESC' | translate }}
- } +
+ +
+
+ } @else { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_SLO_UNAVAILABLE' | translate }} +
+ } + + @if (oidcConfig(); as oidc) {
dns - {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_SERVER_CONFIG' | translate }} {{ 'ADMIN.READ_ONLY' | translate }} @@ -99,124 +120,297 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

- {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_PROVIDER_LABEL' | translate }}
- {{ telegramConfig()!.enabled ? 'true' : 'false' }} + {{ oidc.providerName || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_AUTHORIZATION_URL_LABEL' | translate }}
- {{ - telegramConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.authorizationUrl || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_TOKEN_URL_LABEL' | translate }}
- {{ - telegramConfig()!.botUsername || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.tokenUrl || ('ADMIN.NOT_CONFIGURED' | translate) }}
-
-
- } - - @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && discordConfig()) { -
-
- dns - {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} - - {{ 'ADMIN.READ_ONLY' | translate }} - -
-
+
- {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_USERINFO_URL_LABEL' | translate }}
- {{ - discordConfig()!.clientId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.userInfoUrl || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_CLIENT_ID_LABEL' | translate }}
- {{ - discordConfig()!.clientSecret || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.clientId || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_SCOPES_LABEL' | translate }}
- {{ - discordConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.scopes || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_IDENTITY_CLAIM_LABEL' | translate }}
- {{ - discordConfig()!.guildId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.identityClaim || ('ADMIN.NOT_CONFIGURED' | translate) }}
- {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_DESC' | translate }} + {{ 'ADMIN_SETTINGS.OIDC_USE_PKCE_LABEL' | translate }}
- {{ - discordConfig()!.adminIds || ('ADMIN.NOT_CONFIGURED' | translate) - }} + {{ oidc.usePkce ? 'true' : 'false' }}
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_DESC' | translate }} +
+
+ } + } +
+
+ + @for (group of visibleGroups(); track group.labelKey; let last = $last; let i = $index) { +
+ + + @if (!isCollapsed(group.labelKey)) { +
+ @for (meta of group.settings; track meta.key; let rowLast = $last) { + @if (getSettingValue(meta.key) !== null && isSettingVisible(meta)) { +
+
+ + +
+
+ @if (meta.type === 'boolean') { + + } @else { + + + + @if (meta.key === 'favicon_url') { +
+ favicon preview +
+ } + } +
-
- {{ - discordConfig()!.geofenceForumChannelId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + @if (meta.key === 'favicon_url') { +
+ info_outline + {{ 'ADMIN_SETTINGS.FAVICON_URL_CACHE_WARNING' | translate }} +
+
+ info_outline + {{ 'ADMIN_SETTINGS.FAVICON_URL_CSP_NOTE' | translate }} +
+ } + @if (!rowLast) { + + } + } + } +
+ + @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) { + @if (!telegramConfig()!.enabled) { +
+ info_outline + +
+ } +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} + +
+
+
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} +
+
+ {{ telegramConfig()!.enabled ? 'true' : 'false' }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} +
+
+ {{ + telegramConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_DESC' | translate }} +
+
+ {{ + telegramConfig()!.botUsername || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
+ } + + @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && discordConfig()) { +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} + +
+
+
+
+ {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_DESC' | translate }} +
+
+ {{ + discordConfig()!.clientId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_DESC' | translate }} +
+
+ {{ + discordConfig()!.clientSecret || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_DESC' | translate }} +
+
+ {{ + discordConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_DESC' | translate }} +
+
+ {{ + discordConfig()!.guildId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_DESC' | translate }} +
+
+ {{ + discordConfig()!.adminIds || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_DESC' | translate }} +
+
+ {{ + discordConfig()!.geofenceForumChannelId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
+
+
+
+ } }
@@ -226,47 +420,49 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} -
-
-
- image - -
-
- @for (repo of iconRepos; track repo.name) { -
-
-
- @if (isRepoActive(repo)) { - check_circle - } @else { - radio_button_unchecked - } + @if (!searchQuery()) { +
+
+
+ image + +
+
+ @for (repo of iconRepos; track repo.name) { +
+
+
+ @if (isRepoActive(repo)) { + check_circle + } @else { + radio_button_unchecked + } +
+
+ {{ repo.name }} + {{ repo.base }} +
-
- {{ repo.name }} - {{ repo.base }} +
+ @for (img of repo.previewImages; track img.path) { +
+ + {{ img.name }} +
+ }
-
- @for (img of repo.previewImages; track img.path) { -
- - {{ img.name }} -
- } -
-
- } -
-
+ } +
+
+ } - @if (unknownSettings().length > 0) { + @if (!searchQuery() && unknownSettings().length > 0) {
@@ -292,5 +488,24 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} + + @if (modifiedSettings().size > 0) { +
+ {{ 'ADMIN_SETTINGS.UNSAVED_CHANGES' | translate: { count: modifiedSettings().size } }} + + + +
+ } }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss index d7a94887..a5a5ac2d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss @@ -25,7 +25,7 @@ padding-left: 12px; } .page-content { - padding: 0 24px 48px; + padding: 0 24px 80px; max-width: 860px; } .loading-container { @@ -363,3 +363,162 @@ .favicon-cache-warning { margin-top: 4px; } + +// ─── Live search bar ──────────────────────────────────────────────────────── +.settings-search { + position: sticky; + top: 8px; + z-index: 5; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px 4px 14px; + margin-bottom: 16px; + border-radius: 24px; + background: var(--mat-app-surface, var(--surface-variant, rgba(255, 255, 255, 0.96))); + border: 1px solid var(--divider, rgba(0, 0, 0, 0.12)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.search-leading { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); +} +.search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + color: var(--text-primary, rgba(0, 0, 0, 0.87)); + padding: 8px 0; + + &::placeholder { + color: var(--text-hint, rgba(0, 0, 0, 0.4)); + } +} +.search-clear { + flex-shrink: 0; +} + +// ─── Collapsible section header ───────────────────────────────────────────── +.section-header-toggle { + width: 100%; + border: none; + border-left: 4px solid transparent; + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.15s; + + &:hover { + filter: brightness(0.98); + } +} +.section-header-spacer { + flex: 1; +} +.section-summary { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: none; + color: var(--text-hint, rgba(0, 0, 0, 0.45)); +} +.section-badge { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 2px 8px; + border-radius: 10px; + background: rgba(25, 118, 210, 0.12); + color: #1976d2; + white-space: nowrap; +} +.section-chevron { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--text-secondary, rgba(0, 0, 0, 0.5)); +} + +// ─── Sticky save / discard bar ────────────────────────────────────────────── +.settings-actionbar { + position: sticky; + bottom: 16px; + z-index: 6; + display: flex; + align-items: center; + gap: 12px; + margin-top: 24px; + padding: 12px 16px; + border-radius: 12px; + background: var(--mat-app-surface, var(--surface-variant, rgba(255, 255, 255, 0.98))); + border: 1px solid var(--divider, rgba(0, 0, 0, 0.12)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); + animation: actionbar-slide-up 0.2s ease-out; +} +.actionbar-count { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, rgba(0, 0, 0, 0.87)); +} +.actionbar-spacer { + flex: 1; +} + +@keyframes actionbar-slide-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ─── Staggered section entrance ───────────────────────────────────────────── +.fade-in-up { + animation: fade-in-up 0.3s ease-out both; +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .fade-in-up, + .settings-actionbar { + animation: none; + } +} + +.auth-mode-row { + display: flex; + align-items: center; + gap: 24px; + padding: 16px 20px; + flex-wrap: wrap; +} +.auth-mode-toggle { + flex-shrink: 0; + mat-icon { + margin-right: 6px; + font-size: 18px; + height: 18px; + width: 18px; + vertical-align: text-bottom; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts index c649907e..69af248a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts @@ -1,7 +1,20 @@ -import { ChangeDetectionStrategy, Component, OnInit, DestroyRef, inject, signal, computed } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + DestroyRef, + ElementRef, + HostListener, + ViewChild, + inject, + signal, + computed, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -12,9 +25,10 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; -import { DiscordServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../../core/models'; +import { DiscordServerConfig, OidcServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../../core/models'; import { I18nService } from '../../core/services/i18n.service'; import { SettingsService } from '../../core/services/settings.service'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; /** Union type for backward compatibility during migration */ type AnySettingItem = PwebSetting | SiteSetting; @@ -41,6 +55,38 @@ interface SettingGroup { } const SETTING_GROUPS: SettingGroup[] = [ + { + color: '#0088cc', + icon: 'send', + labelKey: 'ADMIN_SETTINGS.GROUP_TELEGRAM', + settings: [ + { + descriptionKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_DESC', + key: 'enable_telegram', + labelKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_LABEL', + type: 'boolean', + }, + { + descriptionKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_DESC', + key: 'telegram_bot', + labelKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_LABEL', + type: 'text', + }, + ], + }, + { + color: '#5865F2', + icon: 'forum', + labelKey: 'ADMIN_SETTINGS.GROUP_DISCORD', + settings: [ + { + descriptionKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_DESC', + key: 'enable_discord', + labelKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_LABEL', + type: 'boolean', + }, + ], + }, { color: '#1976d2', icon: 'palette', @@ -192,6 +238,12 @@ const SETTING_GROUPS: SettingGroup[] = [ labelKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_SELECT_LABEL', type: 'boolean', }, + { + descriptionKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_DESC', + key: 'disable_user_geofences', + labelKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_LABEL', + type: 'boolean', + }, { descriptionKey: 'ADMIN_SETTINGS.ENABLE_TEMPLATES_DESC', key: 'enable_templates', @@ -224,12 +276,6 @@ const SETTING_GROUPS: SettingGroup[] = [ showWhen: 'enable_roles', type: 'text', }, - { - descriptionKey: 'ADMIN_SETTINGS.ADMIN_ALLOWED_LANGUAGES_DESC', - key: 'allowed_languages', - labelKey: 'ADMIN_SETTINGS.ADMIN_ALLOWED_LANGUAGES_LABEL', - type: 'text', - }, ], }, { @@ -251,38 +297,6 @@ const SETTING_GROUPS: SettingGroup[] = [ }, ], }, - { - color: '#0088cc', - icon: 'send', - labelKey: 'ADMIN_SETTINGS.GROUP_TELEGRAM', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_DESC', - key: 'enable_telegram', - labelKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_LABEL', - type: 'boolean', - }, - { - descriptionKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_DESC', - key: 'telegram_bot', - labelKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_LABEL', - type: 'text', - }, - ], - }, - { - color: '#5865F2', - icon: 'forum', - labelKey: 'ADMIN_SETTINGS.GROUP_DISCORD', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_DESC', - key: 'enable_discord', - labelKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_LABEL', - type: 'boolean', - }, - ], - }, { color: '#2e7d32', icon: 'map', @@ -338,6 +352,8 @@ const SETTING_GROUPS: SettingGroup[] = [ imports: [ FormsModule, MatButtonModule, + MatButtonToggleModule, + MatDialogModule, MatIconModule, MatInputModule, MatFormFieldModule, @@ -354,16 +370,26 @@ const SETTING_GROUPS: SettingGroup[] = [ templateUrl: './admin-settings.component.html', }) export class AdminSettingsComponent implements OnInit { + private static readonly COLLAPSED_STORAGE_KEY = 'poracle-admin-settings-collapsed'; + private readonly allDefinedKeys = new Set([ ...SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key)), 'uicons_pkmn', 'uicons_gym', 'uicons_raid', 'uicons_reward', + // Driven by the Authentication mode switch rather than a generic group row, but still + // a known key so it doesn't fall through to the "Other" catch-all section. + 'enable_oidc', + // Single-logout toggle, surfaced as a dedicated control in the Authentication section. + 'enable_oidc_slo', ]); private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); + private readonly i18n = inject(I18nService); + private readonly internalPrefixes = [ 'webhook_delegates:', 'quick_pick:', @@ -381,7 +407,10 @@ export class AdminSettingsComponent implements OnInit { 'migration_completed', ]; + private readonly originalSnapshot = signal([]); + readonly settings = signal([]); + private readonly settingMap = computed(() => { const map = new Map(); for (const s of this.settings()) map.set(settingKey(s), s.value); @@ -389,10 +418,15 @@ export class AdminSettingsComponent implements OnInit { }); private readonly settingsService = inject(SettingsService); + private readonly snackBar = inject(MatSnackBar); + + /** Current sign-in mode, derived from enable_oidc (opt-in; absent/false = local). */ + readonly authMode = computed<'local' | 'oidc'>(() => (this.getBool('enable_oidc') ? 'oidc' : 'local')); + readonly bulkSaving = signal(false); + readonly collapsedGroups = signal>(AdminSettingsComponent.loadCollapsed()); readonly discordConfig = signal(null); - readonly iconRepos = [ { name: 'Whitewillem (Ingame)', @@ -453,6 +487,21 @@ export class AdminSettingsComponent implements OnInit { readonly modifiedSettings = signal>(new Map()); + readonly oidcConfig = signal(null); + + /** Whether the OIDC provider is fully configured in the server env (gates the SSO option). */ + readonly oidcConfigured = computed(() => this.oidcConfig()?.configured ?? false); + + /** Whether a provider end-session endpoint is configured (gates the single-logout toggle). */ + readonly oidcEndSessionConfigured = computed(() => !!this.oidcConfig()?.endSessionUrl); + + /** Single-logout admin toggle state — absent defaults to ON once the end-session URL is wired. */ + readonly oidcSloEnabled = computed(() => (this.getSettingValue('enable_oidc_slo') ?? '').toLowerCase() !== 'false'); + + @ViewChild('searchInput') searchInput?: ElementRef; + + readonly searchQuery = signal(''); + readonly settingsLoading = signal(true); readonly telegramConfig = signal(null); @@ -463,14 +512,45 @@ export class AdminSettingsComponent implements OnInit { }), ); - readonly visibleGroups = computed(() => - SETTING_GROUPS.filter( - g => + readonly visibleGroups = computed(() => { + // In OIDC sign-in mode the local provider sections are moot — hide them; the read-only + // OIDC config card is shown by the bespoke Authentication section instead. + const localProviderGroups = new Set(['ADMIN_SETTINGS.GROUP_DISCORD', 'ADMIN_SETTINGS.GROUP_TELEGRAM']); + const oidcMode = this.authMode() === 'oidc'; + const query = this.searchQuery().trim(); + const base = SETTING_GROUPS.filter(g => { + if (oidcMode && localProviderGroups.has(g.labelKey)) return false; + return ( g.settings.some(s => this.settingMap().has(s.key)) || (g.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && this.discordConfig() !== null) || - (g.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && this.telegramConfig() !== null), - ), - ); + (g.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && this.telegramConfig() !== null) + ); + }); + if (!query) return base; + return base.map(g => ({ ...g, settings: g.settings.filter(s => this.settingMatches(s)) })).filter(g => g.settings.length > 0); + }); + + private static loadCollapsed(): Set { + try { + const raw = localStorage.getItem(AdminSettingsComponent.COLLAPSED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === 'string')); + } catch { + // Ignore malformed/inaccessible storage. + } + return new Set(); + } + + discardAllModified(): void { + this.settings.set(this.originalSnapshot().map(s => ({ ...s }))); + this.modifiedSettings.set(new Map()); + } + + /** Positive-framing checked state for a boolean setting (ON = enabled). */ + featureEnabled(meta: SettingMeta): boolean { + return this.isInverted(meta) ? !this.getBool(meta.key) : this.getBool(meta.key); + } getBool(key: string): boolean { return (this.getSettingValue(key) ?? '').toLowerCase() === 'true'; @@ -481,6 +561,46 @@ export class AdminSettingsComponent implements OnInit { return map.has(key) ? (map.get(key) ?? null) : undefined; } + groupModifiedCount(group: SettingGroup): number { + const modified = this.modifiedSettings(); + return group.settings.reduce((acc, s) => acc + (modified.has(s.key) ? 1 : 0), 0); + } + + groupSummary(group: SettingGroup): string { + const disableKeys = group.settings.filter(s => s.key.startsWith('disable_')); + if (disableKeys.length === 0) return ''; + // Positive framing: report how many features are enabled (i.e. NOT disabled). + const count = disableKeys.reduce((acc, s) => acc + (this.getBool(s.key) ? 0 : 1), 0); + return this.i18n.instant('ADMIN_SETTINGS.SUMMARY_ENABLED', { count, total: disableKeys.length }); + } + + /** Translated label/description with current search matches wrapped in . */ + highlight(key: string): string { + const text = this.i18n.instant(key); + const escaped = this.escapeHtml(text); + const query = this.searchQuery().trim(); + if (!query) return escaped; + const pattern = new RegExp(`(${this.escapeRegExp(this.escapeHtml(query))})`, 'gi'); + return escaped.replace(pattern, '$1'); + } + + /** Search-active force-expands; otherwise read collapsed membership. */ + isCollapsed(labelKey: string): boolean { + if (this.searchQuery().trim()) return false; + return this.collapsedGroups().has(labelKey); + } + + /** + * Boolean settings are presented in positive framing: a toggle ON always means "feature + * enabled". The stored `disable_*` keys have inverted semantics (true = disabled), so they are + * displayed and written inverted. `enable_*`/other booleans pass through unchanged. The stored + * value is never changed in meaning — only the presentation — so backend feature-gating is + * unaffected. + */ + isInverted(meta: SettingMeta): boolean { + return meta.key.startsWith('disable_'); + } + isRepoActive(repo: { base: string }): boolean { const current = (this.getSettingValue('uicons_pkmn') ?? '').toLowerCase(); return current.startsWith(repo.base.toLowerCase()); @@ -507,6 +627,7 @@ export class AdminSettingsComponent implements OnInit { }, next: settings => { this.settings.set(settings); + this.originalSnapshot.set(settings.map(s => ({ ...s }))); this.settingsLoading.set(false); }, }); @@ -520,12 +641,41 @@ export class AdminSettingsComponent implements OnInit { .getTelegramConfig() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: config => this.telegramConfig.set(config) }); + + this.settingsService + .getOidcConfig() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: config => this.oidcConfig.set(config) }); } onBoolChange(key: string, value: boolean): void { this.applyChange(key, value ? 'True' : 'False'); } + /** Persist a positive-framing toggle, converting back to the stored (possibly inverted) value. */ + onFeatureToggle(meta: SettingMeta, checked: boolean): void { + this.onBoolChange(meta.key, this.isInverted(meta) ? !checked : checked); + } + + @HostListener('document:keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + const tag = target?.tagName?.toLowerCase(); + const isEditable = tag === 'input' || tag === 'textarea' || tag === 'select' || target?.isContentEditable === true; + + if (event.key === 'Escape' && this.searchInput && target === this.searchInput.nativeElement) { + this.searchQuery.set(''); + return; + } + + const isSlash = event.key === '/' && !isEditable; + const isCmdK = (event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K'); + if (isSlash || isCmdK) { + event.preventDefault(); + this.searchInput?.nativeElement.focus(); + } + } + onPreviewError(event: Event): void { const img = event.target as HTMLImageElement; img.classList.add('preview-error'); @@ -593,6 +743,46 @@ export class AdminSettingsComponent implements OnInit { ); } + /** + * Switch the sign-in mode. OIDC is gated on the provider being configured in env, and a + * confirmation warns that local login is bypassed (and about the AUTH_FORCE_LOCAL recovery + * path). The change is staged like any other setting — it persists on Save. + */ + setAuthMode(mode: 'local' | 'oidc'): void { + if (mode === this.authMode()) return; + + if (mode === 'oidc') { + if (!this.oidcConfigured()) return; + const provider = this.oidcConfig()?.providerName || this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC'); + const ref = this.dialog.open(ConfirmDialogComponent, { + data: { + confirmText: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_SWITCH_CONFIRM'), + message: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC_CONFIRM_MSG', { provider }), + title: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC_CONFIRM_TITLE'), + warn: true, + } as ConfirmDialogData, + }); + ref.afterClosed().subscribe(confirmed => { + if (confirmed) this.applyChange('enable_oidc', 'True'); + }); + return; + } + + this.applyChange('enable_oidc', 'False'); + } + + toggleGroup(labelKey: string): void { + const next = new Set(this.collapsedGroups()); + if (next.has(labelKey)) next.delete(labelKey); + else next.add(labelKey); + this.collapsedGroups.set(next); + try { + localStorage.setItem(AdminSettingsComponent.COLLAPSED_STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Ignore persistence failures (e.g. private mode quota). + } + } + private applyChange(key: string, value: string): void { this.settings.update(list => { const exists = list.some(s => settingKey(s) === key); @@ -606,8 +796,20 @@ export class AdminSettingsComponent implements OnInit { }); } + private escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + private escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + private finish(done: number, errors: number, errorMessages: string[] = []): void { this.bulkSaving.set(false); + if (errors === 0) { + // Saved values become the new baseline for discard. + this.originalSnapshot.set(this.settings().map(s => ({ ...s }))); + } const msg = errors === 0 ? this.i18n.instant('ADMIN_SETTINGS.SAVE_SUCCESS', { count: done }) @@ -616,4 +818,11 @@ export class AdminSettingsComponent implements OnInit { : this.i18n.instant('ADMIN_SETTINGS.SAVE_PARTIAL', { done, errors }); this.snackBar.open(msg, this.i18n.instant('COMMON.OK'), { duration: errors ? 5000 : 3000 }); } + + private settingMatches(meta: SettingMeta): boolean { + const query = this.searchQuery().trim().toLowerCase(); + if (!query) return true; + const haystack = `${this.i18n.instant(meta.labelKey)} ${this.i18n.instant(meta.descriptionKey)} ${meta.key}`.toLowerCase(); + return haystack.includes(query); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html index 19e506d5..1c96cc4a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html @@ -58,7 +58,13 @@

{{ 'ADMIN.USERS_TITLE' | translate }}

[defaultUrl]="user.avatarUrl || 'https://cdn.discordapp.com/embed/avatars/0.png'" [userType]="user.type || ''"> - {{ user.name || ('ADMIN.UNNAMED' | translate) }} +
+ {{ user.name || ('ADMIN.UNNAMED' | translate) }} + @let notes = notesLabel(user.notes); + @if (notes) { + {{ notes }} + } +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss index 50ce4b16..d420e60e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss @@ -62,6 +62,19 @@ align-items: center; gap: 10px; } +.user-name-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.user-notes { + font-size: 12px; + color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6)); + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .status-chip { display: inline-block; padding: 2px 10px; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts index d03b9c06..6abf388e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts @@ -72,7 +72,12 @@ export class AdminUsersComponent implements OnInit { let users = this.discordUsers(); if (term) { - users = users.filter(u => u.id.toLowerCase().includes(term) || (u.name || '').toLowerCase().includes(term)); + users = users.filter( + u => + u.id.toLowerCase().includes(term) || + (u.name || '').toLowerCase().includes(term) || + (this.notesLabel(u.notes) || '').toLowerCase().includes(term), + ); } if (status !== 'all') { @@ -216,6 +221,20 @@ export class AdminUsersComponent implements OnInit { this.loadUsers(); } + /** + * Normalizes the Poracle `notes` value for display. PoracleJS/NG can leave a quoted-empty + * sentinel (`""`) or whitespace in the column for users that aren't channels — those should + * render nothing. Strips a single layer of surrounding quotes so a JSON-quoted note shows clean. + */ + notesLabel(notes: string | null): string | null { + if (!notes) return null; + let s = notes.trim(); + if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) { + s = s.slice(1, -1).trim(); + } + return s.length > 0 ? s : null; + } + onPageChange(event: PageEvent): void { this.pageIndex.set(event.pageIndex); this.pageSize.set(event.pageSize); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts index ae4dac1c..ed8fa161 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts @@ -12,6 +12,7 @@ import { UserGeofence } from '../../../core/models'; import { AdminGeofenceService } from '../../../core/services/admin-geofence.service'; import { AreaService } from '../../../core/services/area.service'; import { ConfigService } from '../../../core/services/config.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; // Mock IntersectionObserver for jsdom global.IntersectionObserver = jest.fn().mockImplementation(() => ({ @@ -41,6 +42,7 @@ describe('GeofenceSubmissionsComponent', () => { let component: GeofenceSubmissionsComponent; let adminGeofenceService: { [K in keyof AdminGeofenceService]?: jest.Mock }; let areaService: { getGeofencePolygons: jest.Mock }; + let userGeofenceService: { getRegions: jest.Mock }; let mockDialog: { open: jest.Mock }; let mockSnackBar: { open: jest.Mock }; @@ -132,6 +134,10 @@ describe('GeofenceSubmissionsComponent', () => { getGeofencePolygons: jest.fn().mockReturnValue(of([])), }; + userGeofenceService = { + getRegions: jest.fn().mockReturnValue(of([])), + }; + mockDialog = { open: jest.fn().mockReturnValue({ afterClosed: () => of(null) }), }; @@ -148,6 +154,7 @@ describe('GeofenceSubmissionsComponent', () => { provideHttpClientTesting(), { provide: AdminGeofenceService, useValue: adminGeofenceService }, { provide: AreaService, useValue: areaService }, + { provide: UserGeofenceService, useValue: userGeofenceService }, { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, ], imports: [NoopAnimationsModule], diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts index 385bd137..1ce189a7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts @@ -25,10 +25,11 @@ import { TranslateModule } from '@ngx-translate/core'; import * as L from 'leaflet'; import { firstValueFrom } from 'rxjs'; -import { GeofenceData, UserGeofence } from '../../../core/models'; +import { GeofenceData, GeofenceRegion, UserGeofence } from '../../../core/models'; import { AdminGeofenceService } from '../../../core/services/admin-geofence.service'; import { AreaService } from '../../../core/services/area.service'; import { I18nService } from '../../../core/services/i18n.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/components/confirm-dialog/confirm-dialog.component'; import { GeofenceApprovalDialogComponent, @@ -78,7 +79,9 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe private observer: IntersectionObserver | null = null; private readonly referenceGeofences = signal([]); + private readonly regions = signal([]); private readonly snackBar = inject(MatSnackBar); + private readonly userGeofenceService = inject(UserGeofenceService); readonly activeFilter = signal('all'); readonly allGeofences = signal([]); @@ -234,6 +237,10 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe .getGeofencePolygons() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(geofences => this.referenceGeofences.set(geofences)); + this.userGeofenceService + .getRegions() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ error: () => {}, next: regions => this.regions.set(regions) }); } openDetailDialog(geofence: UserGeofence): void { @@ -249,7 +256,7 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe openReviewDialog(geofence: UserGeofence): void { const ref = this.dialog.open(GeofenceApprovalDialogComponent, { width: '480px', - data: { geofence } as GeofenceApprovalDialogData, + data: { geofence, regions: this.regions() } as GeofenceApprovalDialogData, }); ref @@ -260,7 +267,11 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe if (result.action === 'approve') { this.adminGeofenceService - .approveSubmission(geofence.id, { promotedName: result.promotedName }) + .approveSubmission(geofence.id, { + groupName: result.groupName, + parentId: result.parentId, + promotedName: result.promotedName, + }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ error: () => diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html index 9340a617..ad63a4e1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html @@ -80,6 +80,18 @@

{{ 'AREAS.METHOD_LOCATION' | translate }}

{{ 'AREAS.METHOD_NOTE' | translate }} + +
+
+

+ translate + {{ 'AREAS.NOTIFICATION_LANGUAGE' | translate }} +

+

{{ 'AREAS.NOTIFICATION_LANGUAGE_DESC' | translate }}

+
+ +
+

map diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss index fccbe462..1e19d44d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss @@ -160,6 +160,49 @@ } } +.notification-language { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); + background: var(--surface-2, transparent); + + .notification-language-text { + flex: 1 1 240px; + + h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 4px; + font-size: 15px; + font-weight: 500; + + mat-icon { + color: #ff9800; + font-size: 20px; + width: 20px; + height: 20px; + } + } + + p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary, rgba(0, 0, 0, 0.64)); + } + } + + app-language-selector { + flex: 0 1 280px; + } +} + .section-title { display: flex; align-items: center; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts index b67a4a80..b76900ce 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts @@ -18,6 +18,7 @@ import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { LocationService } from '../../core/services/location.service'; import { AreaMapComponent } from '../../shared/components/area-map/area-map.component'; +import { LanguageSelectorComponent } from '../../shared/components/language-selector/language-selector.component'; import { LocationDialogComponent } from '../../shared/components/location-dialog/location-dialog.component'; import { RegionOption, RegionSelectorComponent } from '../../shared/components/region-selector/region-selector.component'; @@ -49,6 +50,7 @@ interface GroupInfo { MatSnackBarModule, TranslateModule, AreaMapComponent, + LanguageSelectorComponent, RegionSelectorComponent, ], selector: 'app-area-list', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/callback.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/callback.component.ts index b1ccba42..08bce351 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/callback.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/callback.component.ts @@ -28,10 +28,15 @@ export class CallbackComponent implements OnInit { const fragment = this.route.snapshot.fragment || ''; const fragmentParams = new URLSearchParams(fragment); const token = fragmentParams.get('token'); + const refreshToken = fragmentParams.get('refresh_token'); const errorParam = this.route.snapshot.queryParamMap.get('error'); if (errorParam) { const messageKeys: Record = { + oidc_disabled: 'AUTH.ERR_OIDC_DISABLED', + oidc_no_identity: 'AUTH.ERR_OIDC_NO_IDENTITY', + oidc_token_exchange_failed: 'AUTH.ERR_OIDC_TOKEN_EXCHANGE', + oidc_userinfo_failed: 'AUTH.ERR_OIDC_USERINFO', discord_user_fetch_failed: 'AUTH.ERR_DISCORD_FETCH', missing_code: 'AUTH.ERR_MISSING_CODE', token_exchange_failed: 'AUTH.ERR_TOKEN_EXCHANGE', @@ -40,7 +45,7 @@ export class CallbackComponent implements OnInit { const key = messageKeys[errorParam]; this.error.set(key ? this.i18n.instant(key) : this.i18n.instant('AUTH.ERR_GENERIC', { error: errorParam })); } else if (token) { - this.auth.handleTokenFromCallback(token); + this.auth.handleTokenFromCallback(token, refreshToken); } else { this.error.set(this.i18n.instant('AUTH.ERR_NO_TOKEN')); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.html index c580b21d..d4c60b98 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.html @@ -18,11 +18,25 @@

{{ siteTitle() || ('AUTH.SITE_TITLE_DEFAULT' | translate